Every endpoint, parameter, and response schema for Nexiel Verify.
Requests to a production deployment require a bearer token. Mint one via the standard OAuth2 client-credentials grant against https://auth.nexiel.io/realms/nexiel/protocol/openid-connect/token using the client_id/client_secretpair from your dashboard's API Keys page — see this API's own quickstart for the exact curl command.
/healthReturns 503 with status "draining" during the brief shutdown window between receiving SIGTERM and the process actually exiting — see main.ts and BLUE_GREEN.md.
/v1/verify/sessionsBearer auth (production mode)Scoped to the calling client: in production, the bearer token's authenticated identity — `clientId` in the query string is IGNORED in that case, never trusted over it. In sandbox mode (no bearer token), `clientId` is REQUIRED and taken as-is, same "caller-supplied identity" simplification POST /v1/verify/sessions already documents for that mode. Most-recently-created first.
clientId (query)stringlimit (query)string/v1/verify/sessionsBearer auth (production mode)Authenticated (production-mode) requests are per-client rate limited (a token bucket — see `rate-limit.ts`): exceeding it returns a `429` with a `Retry-After` header. Separately, a per-client, self-baselined anomaly signal (`anomaly.ts`) is evaluated on every authenticated request. It covers request VOLUME (this hour’s count vs. this client’s own trailing-24h baseline), GEOGRAPHY (a country never seen before for this client, once at least one other country is already on record), and BEHAVIOR-SHIFT (this hour’s mix of `checkType`/route kinds vs. this client’s own trailing-24h mix, measured as total variation distance). VOLUME has a real, automatic ENFORCEMENT action: a genuine spike temporarily reduces that client’s own rate limit for a bounded, self-expiring cooldown (see `auto-throttle.ts`), recorded as a `CLIENT_AUTO_THROTTLED` audit event. GEOGRAPHY and BEHAVIOR-SHIFT are logged for operators only (a real, queryable record) and never throttle or block a request — a deliberate choice under CLAUDE.md’s EU AI Act human-in-the-loop discipline, not a temporary gap. Both rate limiting and anomaly tracking are scoped to Nexiel Verify traffic independently of Nexiel Screen's own limits for the same client (see packages/db/src/schema/rate-limits.ts).
idempotency-key (header)stringClient-supplied idempotency key, enforced server-side for 24h: same key + same request body replays the original response without repeating the underlying action; same key + a different request is a 409.
clientIdstringrequiredcheckType"age-verification" | "aml-identity" | "str-guest-registration" | "login-authentication" | "restricted-purchase-alcohol-18" | "restricted-purchase-tobacco-18" | "restricted-purchase-lottery-18" | "mobile-driving-licence" | "corporate-mandate-verification"requiredcountryCodestringISO 3166-1 alpha-2 country code. Required for restricted-purchase-* checkTypes (alcohol/tobacco/lottery legal enablement is per-country); ignored otherwise.
Nexiel Age: request only the birthdate claim, used to compute an age_over_18 boolean server-side
{
"clientId": "docs-example-client",
"checkType": "age-verification"
}Nexiel Verify / Nexiel Screen: full identity-linked AML/PEP screening check
{
"clientId": "docs-example-client",
"checkType": "aml-identity"
}CheckFlow.eu: Spain RD 933/2021 short-term-rental guest-registration identity check — no AML/PEP screening
{
"clientId": "docs-example-client",
"checkType": "str-guest-registration"
}Nexiel Login: request only the birthdate claim structurally required by DCQL — the returned pairwise subject identifier is derived from the presentation's own key-binding public key, never from a disclosed claim
{
"clientId": "docs-example-client",
"checkType": "login-authentication"
}age-verification session: dcql_query offers the EU AV mdoc age_over_18 claim OR the PID birthdate claim as alternatives, via credential_sets — a wallet answers exactly one
{
"sessionId": "f1be0a07-e161-4827-a4c3-3337952859ff",
"templateVersion": "age-verification@2",
"authorizationRequest": {
"client_id": "https://verify.nexiel.eu",
"response_type": "vp_token",
"response_mode": "direct_post",
"response_uri": "https://compliance.nexiel.io/v1/verify/sessions/f1be0a07-e161-4827-a4c3-3337952859ff/responses",
"dcql_query": {
"credentials": [
{
"id": "av_age_over_18",
"format": "mso_mdoc",
"meta": {
"doctype_value": "eu.europa.ec.av.1"
},
"claims": [
{
"path": [
"eu.europa.ec.av.1",
"age_over_18"
]
}
]
},
{
"id": "pid_birthdate",
"format": "dc+sd-jwt",
"meta": {
"vct_values": [
"urn:eudi:pid:1"
]
},
"claims": [
{
"path": [
"birthdate"
]
}
]
}
],
"credential_sets": [
{
"options": [
[
"av_age_over_18"
],
[
"pid_birthdate"
]
],
"required": true
}
]
},
"nonce": "-SZgEPglIAiTT0LcV1smKeyCna49ccKyqNdsz98jxj4",
"state": "w3b6d4ohOvuYDe3t50rl7BroLjlBl_c5I1ROHGIyxcU"
},
"authorizationRequestUri": "openid4vp://authorize?client_id=https%3A%2F%2Fverify.nexiel.eu&response_type=vp_token&...(truncated)",
"expiresAt": "2026-07-05T13:09:09.445Z"
}aml-identity session: dcql_query names given_name/family_name/birthdate/nationalities
{
"sessionId": "4fc1ad3c-a197-4b09-bc62-68842fc69947",
"templateVersion": "aml-identity@1",
"authorizationRequest": {
"client_id": "https://verify.nexiel.eu",
"response_type": "vp_token",
"response_mode": "direct_post",
"response_uri": "https://compliance.nexiel.io/v1/verify/sessions/4fc1ad3c-a197-4b09-bc62-68842fc69947/responses",
"dcql_query": {
"credentials": [
{
"id": "pid_aml_identity",
"format": "dc+sd-jwt",
"meta": {
"vct_values": [
"urn:eudi:pid:1"
]
},
"claims": [
{
"path": [
"given_name"
]
},
{
"path": [
"family_name"
]
},
{
"path": [
"birthdate"
]
},
{
"path": [
"nationalities"
]
}
]
}
]
},
"nonce": "amIcbtsnz9umxzWKu--gPBHPUUmcH5R4TukgGSw-ALI",
"state": "jqz4jEBGULUkWgkp_lcpBsbgbV3adNuJvx5xxk55gus"
},
"authorizationRequestUri": "openid4vp://authorize?client_id=https%3A%2F%2Fverify.nexiel.eu&response_type=vp_token&...(truncated)",
"expiresAt": "2026-07-05T17:27:16.353Z"
}str-guest-registration session (CheckFlow.eu): dcql_query names given_name/family_name/birthdate/nationalities/document_number — the same PID identity claims as aml-identity, plus document_number
{
"sessionId": "9a2e4b1c-6f3d-4a8e-9c7b-1d2e3f4a5b6c",
"templateVersion": "str-guest-registration@1",
"authorizationRequest": {
"client_id": "https://verify.nexiel.eu",
"response_type": "vp_token",
"response_mode": "direct_post",
"response_uri": "https://compliance.nexiel.io/v1/verify/sessions/9a2e4b1c-6f3d-4a8e-9c7b-1d2e3f4a5b6c/responses",
"dcql_query": {
"credentials": [
{
"id": "pid_str_guest_registration",
"format": "dc+sd-jwt",
"meta": {
"vct_values": [
"urn:eudi:pid:1"
]
},
"claims": [
{
"path": [
"given_name"
]
},
{
"path": [
"family_name"
]
},
{
"path": [
"birthdate"
]
},
{
"path": [
"nationalities"
]
},
{
"path": [
"document_number"
]
}
]
}
]
},
"nonce": "k3y8m1nz-nonce-example-truncated-Rq4vD9wXeUOgSTAzL",
"state": "p7t2q5x-state-example-truncated-mBKf8jVN0hYd3ZcRu"
},
"authorizationRequestUri": "openid4vp://authorize?client_id=https%3A%2F%2Fverify.nexiel.eu&response_type=vp_token&...(truncated)",
"expiresAt": "2026-08-26T17:27:16.353Z"
}login-authentication session: dcql_query names only birthdate — the smallest non-empty claim set this checkType's own template validator allows
{
"sessionId": "c4d8f2a1-3b6e-4c9d-8a1f-5e6b7c8d9e0f",
"templateVersion": "login-authentication@1",
"authorizationRequest": {
"client_id": "https://verify.nexiel.eu",
"response_type": "vp_token",
"response_mode": "direct_post",
"response_uri": "https://compliance.nexiel.io/v1/verify/sessions/c4d8f2a1-3b6e-4c9d-8a1f-5e6b7c8d9e0f/responses",
"dcql_query": {
"credentials": [
{
"id": "pid_login_authentication",
"format": "dc+sd-jwt",
"meta": {
"vct_values": [
"urn:eudi:pid:1"
]
},
"claims": [
{
"path": [
"birthdate"
]
}
]
}
]
},
"nonce": "nH3xVpQz-nonce-example-truncated-K9wLd2fRtYbC",
"state": "sT7mB4x-state-example-truncated-QpJ6vNc3ZwXe"
},
"authorizationRequestUri": "openid4vp://authorize?client_id=https%3A%2F%2Fverify.nexiel.eu&response_type=vp_token&...(truncated)",
"expiresAt": "2026-08-26T17:27:16.353Z"
}/v1/verify/sessions/{sessionId}Bearer auth (production mode)Returns exactly the non-PII fields verify_sessions/verify_decisions persist — never anything derived from the transient wallet claims verify-workflow.ts holds mid-request. `decision` is null until `status` is "completed"; `frozenDecisionArtifact` inside it is the exact engine_version/gatesHash/ensembleWeightsHash/listSnapshotIds pinned AT DECISION TIME (never recomputed from whatever gates.json happens to be loaded now). This route can never resolve or clear a POSSIBLE_MATCH result — it only reads one back. This is ALSO the one delivery channel for an 'aml-identity' or 'str-guest-registration' decision's encrypted evidence pack (`decision.evidencePackCiphertext`) — never the wallet's own POST .../responses callback reply. Delivered at most once per session: the first successful "claim"-mode response that includes it consumes that one-time delivery, and every later request (even a retry) gets `null` there instead, regardless of whether the underlying ciphertext has been purged yet. Pass `?evidencePackDelivery=peek` to read a session back WITHOUT any risk of consuming that delivery — see the query parameter's own description. PRODUCTION MODE ALSO accepts an `X-Nexiel-Verify-Session-Token` header (see InitiateSessionResponseSchema.sessionToken's own description) as an ALTERNATIVE to the Authorization bearer credential — a narrower, safe-to-expose-client-side capability scoped to reading back ONLY the one session it was minted for. A request authenticated this way NEVER receives a non-null `evidencePackCiphertext`, regardless of the `evidencePackDelivery` query parameter — that one-time delivery is reserved for a caller holding the real production credential (the client's own backend), so an end user's own browser polling directly can never race a client's backend for it.
evidencePackDelivery (query)stringControls whether THIS request may consume the session's one-time AMLR evidence-pack delivery. "claim" (the default — identical behavior to omitting this parameter entirely) is the real, client-facing delivery path: if a ciphertext is ready, this response delivers it, and that is the one and only time it will ever appear in any response body. "peek" NEVER claims it: `decision.evidencePackCiphertext` is always null in this mode, even when a pack is genuinely sitting there ready to deliver, and the session's one-time delivery window is left completely untouched — a real "claim" request can still receive it afterward, exactly as if the peek had never happened. Intended for inspection/debugging reads (e.g. the Nexiel Agent Toolkit MCP server's get_verification_session tool) that must never risk silently burning a client's only real chance to receive their evidence pack merely by reading a session back.
sessionId (path)stringrequiredx-nexiel-verify-session-token (header)stringA session-scoped read token returned once by POST /v1/verify/sessions (`sessionToken` in its response body). An alternative to the `Authorization` bearer credential, scoped to reading back ONLY the ONE session named in this request's path — see that field's own description.
/v1/verify/sessions/{sessionId}/responsesThis route's real authorization is the session's own unguessable state/nonce (OpenID4VP direct_post) — a wallet holds no Nexiel API key, so no bearer token is required here, unlike every other route in this service. Defense in depth: IF a caller does attach an Authorization header anyway (e.g. a misrouted Agent Toolkit call — see auth.ts's "Agent Toolkit scope model"), it is still validated, and an agent-readonly-scoped key is rejected — this route completes/mutates a verification, which is explicitly outside that scope's initiate-or-read-only contract. Accepts BOTH `application/json` and, per the OpenID4VP `direct_post` spec a real wallet actually uses, `application/x-www-form-urlencoded` (with `vp_token` as a JSON-encoded string field inside the form body) — see this handler's own preValidation step.
sessionId (path)stringrequiredstatestringrequiredvp_tokenobject<string, string | string[]>errorstringerror_descriptionstringA wallet's direct_post callback answering the PID/birthdate alternative (verify-api computes the age_over_18 boolean from it server-side and discards the raw value immediately — the AV mdoc alternative would instead key vp_token by "av_age_over_18" and carry a base64url-encoded CBOR DeviceResponse). vp_token is truncated here for readability: a real SD-JWT VC presentation is a compact-serialized signed JWT plus disclosures, not something meaningful to hand-write.
{
"state": "w3b6d4ohOvuYDe3t50rl7BroLjlBl_c5I1ROHGIyxcU",
"vp_token": {
"pid_birthdate": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9...(truncated SD-JWT VC presentation)..."
}
}A wallet reporting it could not satisfy the request (neither the AV attestation nor the PID is held) — error/error_description INSTEAD OF vp_token, per OpenID4VP's error-response contract.
{
"state": "w3b6d4ohOvuYDe3t50rl7BroLjlBl_c5I1ROHGIyxcU",
"error": "access_denied",
"error_description": "no credential satisfying the requested credential_sets is available in this wallet"
}age-verification: wallet discloses a birthdate 18 years or more in the past, so ageOver18 computes to true. amlResult/pepFlag/sanctionedFlag/frozenDecisionArtifact are all null: an age-verification check never runs the AML/PEP matching engine, so there is nothing to screen and no decision to freeze.
{
"sessionId": "f1be0a07-e161-4827-a4c3-3337952859ff",
"method": "eudi_wallet",
"booleanProofs": {
"ageOver18": true
},
"amlResult": null,
"pepFlag": null,
"sanctionedFlag": null,
"listsChecked": [],
"humanReviewRequired": false,
"templateVersion": "age-verification@2",
"frozenDecisionArtifact": null
}age-verification: wallet discloses a birthdate less than 18 years in the past, so ageOver18 computes to false
{
"sessionId": "2a6e1c3d-9b0a-4f7e-9c1d-8e5f6a7b8c9d",
"method": "eudi_wallet",
"booleanProofs": {
"ageOver18": false
},
"amlResult": null,
"pepFlag": null,
"sanctionedFlag": null,
"listsChecked": [],
"humanReviewRequired": false,
"templateVersion": "age-verification@2",
"frozenDecisionArtifact": null
}aml-identity: sandbox-seeded name match with a contradicting date of birth, POSSIBLE_MATCH, never auto-cleared or auto-confirmed, human review required (EU AI Act).
{
"sessionId": "4fc1ad3c-a197-4b09-bc62-68842fc69947",
"method": "eudi_wallet",
"booleanProofs": {
"claimsVerified": true
},
"amlResult": "POSSIBLE_MATCH",
"pepFlag": false,
"sanctionedFlag": true,
"listsChecked": [
"sandbox"
],
"humanReviewRequired": true,
"templateVersion": "aml-identity@1",
"frozenDecisionArtifact": {
"engineVersion": "0.1.0-provisional",
"gatesHash": "212b018655f47f1514c4b404a02e643ef027b5039964d5c68dcdd5ec0dff8f3b",
"ensembleWeightsHash": "4a2723f74864b3371c860bbb93c3d6fbfde7dcc21e99e1242ddad3ff366cf69d",
"listSnapshotIds": []
}
}str-guest-registration: no AML/PEP screening runs at all for this checkType — amlResult/pepFlag/sanctionedFlag/frozenDecisionArtifact are all null, same null-when-nothing-ran idiom age-verification's own example above uses. The client's own encrypted evidence pack (identity claims + document_number, if a key is registered) is delivered separately via GET /v1/verify/sessions/{sessionId} — never on this synchronous callback response.
{
"sessionId": "9a2e4b1c-6f3d-4a8e-9c7b-1d2e3f4a5b6c",
"method": "eudi_wallet",
"booleanProofs": {
"claimsVerified": true
},
"amlResult": null,
"pepFlag": null,
"sanctionedFlag": null,
"listsChecked": [],
"humanReviewRequired": false,
"templateVersion": "str-guest-registration@1",
"frozenDecisionArtifact": null
}/v1/login/.well-known/openid-configurationOpenID Connect Discovery 1.0 §4: since the issuer (`${publicBaseUrl}/v1/login`) itself has a path component, this exact path — APPENDING, never inserting before, `/.well-known/openid-configuration` — is the spec-correct discovery location, fully namespaced under this PathPrefix.
/v1/login/.well-known/jwks.jsonEvery ACTIVE and ROTATED (never REVOKED) login_signing_keys row — an id_token signed just before a rotation stays verifiable through the overlap window.
/v1/login/authorizeA GET that returns human-facing HTML directly, matching Keycloak's own /realms/{realm}/protocol/openid-connect/auth shape — never a JSON API. Validates client_id/redirect_uri/PKCE against login_oidc_clients, starts a real EUDI wallet verification session via @nexiel/verify's existing initiateVerification, and renders a QR code against the real authorizationRequestUri that call returns. The page's own client-side JS polls GET /v1/login/authorize/:authorizationRequestId/status and navigates the browser to the customer's redirect_uri once that check completes. If the browser presents a valid, unexpired `nx_login_sso` session cookie for this SAME client_id (set on a prior real completion — see the status route below), the wallet round trip is skipped entirely and this responds with an immediate 302 straight to the customer's redirect_uri, never rendering the QR page at all. An unresolvable client_id or a redirect_uri outside this client's registered allowlist renders a static error page (never a redirect — the standard OAuth open-redirect defense); every other validation failure redirects back to the (now-trusted) redirect_uri with `?error=...&error_description=...&state=...`, per RFC 6749 §4.1.2.1.
response_type (query)stringclient_id (query)stringredirect_uri (query)stringscope (query)stringstate (query)stringnonce (query)stringcode_challenge (query)stringcode_challenge_method (query)string/v1/login/authorize/{authorizationRequestId}/statusThis route's real authorization is the unguessable authorizationRequestId itself — the same "an unguessable id IS the real authorization" precedent this service already documents for the wallet's own direct_post callback route (no bearer token exists for an anonymous end user's browser either). Mints the one-time OAuth code on the FIRST poll that observes "completed" — see login_authorization_codes' own schema doc comment for the atomic, race-safe mechanism. That same first poll also sets a fresh `nx_login_sso` session cookie (HttpOnly, Path=/v1/login), so a later `GET /v1/login/authorize` from this SAME browser, for this SAME client_id, can skip the wallet round trip entirely — see that route's own description.
authorizationRequestId (path)stringrequired/v1/login/tokenclient_secret_basic (Authorization: Basic) or client_secret_post (client_id/client_secret as body fields) authentication against login_oidc_clients. code_verifier is REQUIRED and checked against the code_challenge the original /authorize request committed to (S256 only). Returns a real, compact-serialized ES256 id_token whose `sub` is this (customer, wallet holder) pair's stable login_subjects.id.
grant_typestringcodestringredirect_uristringcode_verifierstringclient_idstringclient_secretstring/v1/verify/gdpr/exportBearer auth (production mode)Scoped strictly to the authenticated bearer token's own client_id. Returns every verify_sessions/verify_decisions row for this caller (identical shape to GET /v1/verify/sessions — neither table holds any PII by construction) plus this caller's own audit_log entries.
/v1/verify/gdpr/erasure-requestsBearer auth (production mode)Deletes every verify_sessions and verify_decisions row for the calling API key -- both genuinely fully erasable on request, since neither holds any PII by construction (this service's whole design already deletes raw wallet claims at the end of one request). NEVER touches audit_log (an append-only hash chain shared across every client — CLAUDE.md's audit-trail requirement). Deletes ANY session for this caller, including a still-'pending' one — an in-flight wallet round-trip for a deleted session would then 404 on its own callback, an accepted trade-off of an explicit erasure request.
/v1/verify/usageBearer auth (production mode)Scoped strictly to the authenticated bearer token's own client_id -- never another client's usage. `requestVolume` comes from this service's existing per-client, per-service, hourly request-volume tracking (the same table anomaly detection already writes to); `decisions` comes from this service's existing audit_log VERIFICATION_DECISION (EUDI wallet) events. Defaults to a trailing 90-day window ending now when `from`/`to` are omitted; the widest single request this endpoint accepts is 366 days.
from (query)stringStart of the reporting window (inclusive), UTC calendar date. Defaults to 90 days before `to`.
to (query)stringEnd of the reporting window (exclusive), UTC calendar date. Defaults to today.