Nexiel Verify API

Every endpoint, parameter, and response schema for Nexiel Verify.

OpenAPI 3.1.0Version 0.1.0https://compliance.nexiel.io

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.

GET/health

Liveness / service-mode check

Returns 503 with status "draining" during the brief shutdown window between receiving SIGTERM and the process actually exiting — see main.ts and BLUE_GREEN.md.

Responses

200
503
GET/v1/verify/sessionsBearer auth (production mode)

List verification sessions for the calling client

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.

Parameters

clientId (query)string
limit (query)string

Responses

200
400
401
500
POST/v1/verify/sessionsBearer auth (production mode)

Initiate an EUDI wallet verification session (OpenID4VP)

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).

Parameters

idempotency-key (header)string

Client-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.

Request body

clientIdstringrequired
checkType"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"required
countryCodestring

ISO 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"
}

Responses

200

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"
}
400
401
403
409
429
500
GET/v1/verify/sessions/{sessionId}Bearer auth (production mode)

Read back a verification session's status and (if completed) its decision

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.

Parameters

evidencePackDelivery (query)string

Controls 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)stringrequired
x-nexiel-verify-session-token (header)string

A 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.

Responses

200
401
404
429
500
POST/v1/verify/sessions/{sessionId}/responses

Wallet's direct_post callback for an EUDI verification session

This 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.

Parameters

sessionId (path)stringrequired

Request body

statestringrequired
vp_tokenobject<string, string | string[]>
errorstring
error_descriptionstring

A 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"
}

Responses

200

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
}
400
403
404
409
410
500
GET/v1/login/.well-known/openid-configuration

Nexiel Login OIDC discovery document

OpenID 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.

Responses

200
GET/v1/login/.well-known/jwks.json

Nexiel Login's id_token signing keys (JWKS)

Every ACTIVE and ROTATED (never REVOKED) login_signing_keys row — an id_token signed just before a rotation stays verifiable through the overlap window.

Responses

200
GET/v1/login/authorize

Hosted login page (also the OAuth 2.0 / OIDC authorize endpoint)

A 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.

Parameters

response_type (query)string
client_id (query)string
redirect_uri (query)string
scope (query)string
state (query)string
nonce (query)string
code_challenge (query)string
code_challenge_method (query)string

Responses

200
GET/v1/login/authorize/{authorizationRequestId}/status

The hosted login page's own unauthenticated status-poll route

This 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.

Parameters

authorizationRequestId (path)stringrequired

Responses

200
404
POST/v1/login/token

OAuth 2.0 Authorization Code + PKCE token exchange (RFC 6749 §4.1.3)

client_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.

Request body

grant_typestring
codestring
redirect_uristring
code_verifierstring
client_idstring
client_secretstring

Responses

200
400
401
GET/v1/verify/gdpr/exportBearer auth (production mode)

GDPR Article 15 (right of access): export everything Nexiel Verify holds about the calling API key

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.

Responses

200
401
500
POST/v1/verify/gdpr/erasure-requestsBearer auth (production mode)

GDPR Article 17 (right to erasure): delete this caller's verify_sessions/verify_decisions

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.

Responses

200
401
500
GET/v1/verify/usageBearer auth (production mode)

Per-client API usage: request volume by day/month, and verification decisions by check type

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.

Parameters

from (query)string

Start of the reporting window (inclusive), UTC calendar date. Defaults to 90 days before `to`.

to (query)string

End of the reporting window (exclusive), UTC calendar date. Defaults to today.

Responses

200
400
401
500