---
title: "Identity verification API"
description: "Verify identities, run hosted verification sessions, batch and re-verify, store evidence, and receive signed KYC, KYB and KYM webhooks."
canonical_url: "https://noxtica.com/docs/api/reference/kyc/"
markdown_url: "https://noxtica.com/docs/api/reference/kyc/index.md"
last_updated: 2026-10-27
published: 2026-10-27
section: "server-apis"
section_title: "Server APIs"
openapi_release: "v0.4.0"
openapi_spec_version: "1.2.0-2026-10-27"
site: "Noxtica"
language: "en"
source: markdown
---

# Identity verification API

Verify identities, run hosted verification sessions, batch and re-verify, store evidence, and receive signed KYC, KYB and KYM webhooks.

| Environment | Base URL |
| --- | --- |
| Production | `https://collect.noxtica.com` |

Credential: `ApiKeyAuth`, `SessionTokenAuth`. See [Authentication](https://noxtica.com/docs/api/authentication/).

## Verify an identity {#kycVerify}

`POST /v1/kyc/verify`

Two branches, selected by the body's `action` field (`verify` is the implicit default when `action` is omitted — every pre-existing caller is unaffected):

`action:'verify'` admits, screens, policy-enforces, and finalizes a single identity verification against the tenant's configured provider. The admission → spend-reservation → provider-call → finalize pipeline is shared byte-for-byte with the hosted-session submit path (`POST /v1/kyc/session-submit`) and the batch processor — there is exactly one verify pipeline in this system. It requires a `nonce` obtained from a prior `action:'mint'` call to the SAME endpoint (the only route that legitimately issues one) or a valid hosted-session-derived nonce.

`action:'mint'` issues that admission nonce: a fresh, tenant-scoped, single-use nonce bound in KV to the calling tenant (TTL 300s). It is the ONLY way a server-to-server caller can obtain a nonce a subsequent `action:'verify'` call will accept — without it, `evidence-handle`/`cross-match`/`reverify` are unreachable S2S, since every one of those requires an admitted `verificationId` a real `verify` call produces. Mint runs the SAME auth/module-gate/legal-basis preflight as verify, but NEVER meters, NEVER reserves spend, and makes ZERO provider calls.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | Yes | A replayed key returns the byte-identical stored response — no second provider call, no double charge. At least 8 characters, at most 128 characters. |

### Request body

Content type `application/json`, required.

The body of `POST /v1/kyc/verify` — either branch selected by `action` (`verify` is the implicit default when `action` is omitted). Named as its own component (rather than an inline `oneOf`) so `openapi/generate.sh`'s csharp/generichost codegen produces a distinct composed wrapper type instead of colliding with either member schema's own generated name.

**Variant KycVerifyMintRequest**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | Yes | Selects the mint branch of this endpoint. Allowed values: `mint`. |

**Variant KycVerifyVerifyRequest**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | string | No | Optional; `verify` is the implicit default when this field is omitted — every pre-existing caller is unaffected. Allowed values: `verify`. Default `verify`. |
| `nonce` | string | Yes | A fresh, tenant-scoped single-use admission nonce (minted by your own prior client-side flow / SDK step). |
| `id_type` | string | Yes | Document/verification type hint for the resolved provider adapter (aliases: idType). |
| `purpose` | string | No | One of the closed evidence-purpose catalog values. Default `identity_verification`. |
| `audience` | string | No | Defaults to the caller's own tenant id. |
| `deviceContext` | boolean | No | When true and device-intel is armed for the tenant, the response carries an ADVISORY deviceTrust block. Never influences the KYC verdict itself. |
| `payload` | object | No | Provider-specific verification payload (never logged or persisted verbatim). `verifiedDateOfBirth` and `minimumAge` keys are always stripped server-side before reaching any adapter — supplying them has no effect (defense-in-depth against self-attested age/DOB). |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | `action:'mint'`: a freshly minted admission nonce (`KycVerifyMintResponse`) — zero provider calls, zero spend. `action:'verify'`: a resolved verify attempt (verified, unverified, or an infra `error` verdict), a nonce-replay of a prior identical request, or an admission-level refusal (`KycVerifyResponse`). `verdict:'error'` and every nonce-fault outcome (e.g. `nonce_unknown` for a never-minted or wrong-tenant nonce, `nonce_expired` for one past its 300s TTL) are HTTP 200 by design (FR-6) — a happy-path 5xx is never returned for `action:'verify'`; mint's own KV-write fault is the one 5xx exception (503, below). |  | `application/json` |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `402` | `spend_cap_reached` — `action:'verify'` only. The request was admitted, but no active pricing is configured for your organisation, so the verification was refused before any provider call. Never returned for `action:'mint'` (a mint never reserves spend). See the `403` response for the other refusal of this kind. | `spend_cap_reached` | `application/json` |
| `403` | Either the presented key lacks the required scope (`ScopeForbidden` shape), or — `action:'verify'` only — `tenant_inactive`: your organisation became inactive between nonce admission and the spend check, so the verification was refused. No provider call is made either way. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | A conflicting in-flight or idempotency-key-bound request (`action:'verify'` only). | `example-code`, `idempotency_conflict` | `application/json` ConflictError |
| `413` | `body_too_large` — the request exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `action:'mint'`: the nonce-issuing KV write failed (fail-safe: no partially-written or unusable nonce is ever returned to the caller). `action:'verify'`: `kyc_verify_unavailable` — a transient fault while resolving pricing or reserving spend. Retryable; `Retry-After` carries the suggested backoff in seconds. Distinct from `spend_cap_reached` (402, a real business-state refusal, never an infrastructure fault); the request is not terminally denied. | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |
| `503` | `Retry-After` | integer | Seconds until a retry is likely to succeed (`action:'verify'` faults only). |

**Response fields** (`200`, `application/json`)

**Variant KycVerifyMintResponse**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `nonce` | string (uuid) | Yes | Pass back verbatim as `nonce` on the follow-up `action:'verify'` (or omitted-action) call, from the SAME tenant, before it expires. |
| `ttlSeconds` | integer | Yes | Absolute KV expiry from mint time (300s as of 2026-09-13, `KYC_NONCE_TTL_SECONDS`). A verify attempt after this window elapses returns `reasonCodes:[nonce_expired]`; an unminted, already-consumed, or wrong-tenant nonce returns `reasonCodes:[nonce_unknown]`. |

**Variant KycVerifyResponse**

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `verdict` | string | Yes | `error` is an infra/provider fault, never a statement about the subject — always HTTP 200 (FR-6). Allowed values: `verified`, `unverified`, `error`. |
| `verificationLevel` | string | Yes | Allowed values: `verified`, `partial`, `unverified`, `inconclusive`. |
| `reasonCodes` | array of string | Yes |  |
| `verificationId` | string (uuid) | Yes |  |
| `requestId` | string | Yes |  |
| `legalBasisRef` | string | No | Present only when this request's tenant is running under classified legal-basis receipt gating. |
| `nfcAssurance` | object | No | Present only for eMRTD/passport-chip verification paths. |
| `deviceTrust` | object | No | Present only when the request set deviceContext:true and device-intel evidence is available. ADVISORY ONLY — never fed back into verdict/reasonCodes. |

**Response example** (`200`)

```json
{
  "ok": true,
  "nonce": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "ttlSeconds": 300
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/verify" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Idempotency-Key: example-Idempotency-Key" \
  -H "Content-Type: application/json" \
  -d '{
  "action": "mint"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/verify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Idempotency-Key': 'example-Idempotency-Key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    action: 'mint',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/verify",
    data=json.dumps({
        "action": "mint",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Idempotency-Key": "example-Idempotency-Key",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Cross-match a self-uploaded proof-of-address document {#kycAddressDocumentCrossMatch}

`POST /v1/kyc/verify/{verificationId}/address-document`

Re-OCRs a proof-of-address document (already uploaded as KYC evidence) and the target verification's already-captured ID-front document, then runs a deterministic name/address/freshness cross-match. This is the **only** cross-validation capability Noxtica offers today — it is a self-uploaded-document cross-match, **never** an authoritative registry lookup (USPS/Loqate/Experian), as the response's own `disclosure` field states. There is no generic OCR↔MRZ↔barcode consistency check.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `verificationId` | path | string (uuid) | Yes |  |

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `evidenceObjectId` | string | Yes | The `objectId` of an already-finalized (`usable`) evidence object of kind `proof_of_address`, minted via `POST /v1/kyc/evidence/handle` + `POST /v1/kyc/evidence/finalize` against this same `verificationId`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | A resolved cross-match verdict. |  | `application/json` |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | The verification has no captured front-document evidence to cross-match against. | `invalid_request` | `application/json` ErrorEnvelope |
| `413` | `body_too_large` — the request exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | Evidence unavailable, or the owned OCR inference service could not be reached. | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `verdict` | string | Yes | Allowed values: `pass`, `review`, `reject`. |
| `disclosure` | string | Yes | The fixed, always-identical non-authoritative-result disclosure string. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "verdict": "pass",
  "disclosure": "example-disclosure",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/verify/00000000-0000-4000-8000-000000000000/address-document" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "evidenceObjectId": "obj-front"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/verify/00000000-0000-4000-8000-000000000000/address-document', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    evidenceObjectId: 'obj-front',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/verify/00000000-0000-4000-8000-000000000000/address-document",
    data=json.dumps({
        "evidenceObjectId": "obj-front",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Submit a batch verification manifest {#kycVerifyBatchSubmit}

`POST /v1/kyc/verify/batch`

Submits a CSV (`text/csv`) or JSON (`application/json`, `{"items":[...]}`) manifest of up to 500 rows. Each well-formed row is admitted **asynchronously**, off the response path, through the identical `POST /v1/kyc/verify` admission entry point — this endpoint does not verify anything itself, it only records per-item outcomes.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | Yes | A replayed key returns the byte-identical stored response — no second provider call, no double charge. At least 8 characters, at most 128 characters. |

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `items` | array of object | Yes | At most 500 items. |
| `items[]` | object | No |  |
| `items[].id_type` | string | Yes |  |
| `items[].purpose` | string | No | Default `identity_verification`. |
| `items[].payload` | object | No |  |

Content type `text/csv`, required.

Type: string.

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | A replayed idempotency key — the original batch job, unchanged. |  | `application/json` KycBatchSubmitResponse |
| `202` | Accepted; items begin admitting asynchronously. |  | `application/json` KycBatchSubmitResponse |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `413` | `body_too_large` — the request exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `415` | Unrecognized content type. | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | Batch admission is temporarily unavailable (digest secret unset). | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `batchId` | string | No |  |
| `status` | string | No | Allowed values: `processing`, `completed`, `cancelled`. |
| `itemCount` | integer | No |  |
| `items` | array of KycBatchSubmittedItem | No |  |
| `items[]` | object (KycBatchSubmittedItem) | No |  |
| `items[].rowIndex` | integer | No |  |
| `items[].status` | string | No | Allowed values: `pending`, `admitted`, `evaluating`, `completed`, `rejected`, `cancelled`. |
| `items[].errorCode` | one of KycBatchItemErrorCode or any, nullable | No |  |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "batchId": "batch_5a1f9c3e7b2d4a6f8091c2d3e4f5a6b7",
  "status": "processing",
  "itemCount": 1,
  "items": [
    {
      "rowIndex": 0,
      "status": "pending",
      "errorCode": "missing_id_type"
    }
  ],
  "requestId": "req-test"
}
```

**Response fields** (`202`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `batchId` | string | No |  |
| `status` | string | No | Allowed values: `processing`, `completed`, `cancelled`. |
| `itemCount` | integer | No |  |
| `items` | array of KycBatchSubmittedItem | No |  |
| `items[]` | object (KycBatchSubmittedItem) | No |  |
| `items[].rowIndex` | integer | No |  |
| `items[].status` | string | No | Allowed values: `pending`, `admitted`, `evaluating`, `completed`, `rejected`, `cancelled`. |
| `items[].errorCode` | one of KycBatchItemErrorCode or any, nullable | No |  |
| `requestId` | string | No |  |

**Response example** (`202`)

```json
{
  "ok": true,
  "batchId": "batch_5a1f9c3e7b2d4a6f8091c2d3e4f5a6b7",
  "status": "processing",
  "itemCount": 1,
  "items": [
    {
      "rowIndex": 0,
      "status": "pending",
      "errorCode": "missing_id_type"
    }
  ],
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/verify/batch" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Idempotency-Key: example-Idempotency-Key" \
  -H "Content-Type: application/json" \
  -d '{
  "items": [
    {
      "id_type": "passport",
      "purpose": "identity_verification",
      "payload": {}
    }
  ]
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/verify/batch', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Idempotency-Key': 'example-Idempotency-Key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    items: [
      {
        id_type: 'passport',
        purpose: 'identity_verification',
        payload: {},
      },
    ],
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/verify/batch",
    data=json.dumps({
        "items": [
            {
                "id_type": "passport",
                "purpose": "identity_verification",
                "payload": {},
            },
        ],
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Idempotency-Key": "example-Idempotency-Key",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Read a batch job's aggregate status {#kycVerifyBatchStatus}

`GET /v1/kyc/verify/batch/{batchId}/status`

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `batchId` | path | string | Yes |  |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The job-level aggregate. |  | `application/json` KycBatchStatusResponse |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `batchId` | string | No |  |
| `status` | string | No | Allowed values: `processing`, `completed`, `cancelled`. |
| `itemCount` | integer | No |  |
| `pendingCount` | integer | No |  |
| `admittedCount` | integer | No |  |
| `completedCount` | integer | No |  |
| `rejectedCount` | integer | No |  |
| `cancelledCount` | integer | No |  |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "batchId": "batch_5a1f9c3e7b2d4a6f8091c2d3e4f5a6b7",
  "status": "processing",
  "itemCount": 1,
  "pendingCount": 0,
  "admittedCount": 1,
  "completedCount": 0,
  "rejectedCount": 0,
  "cancelledCount": 0,
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl "https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/status" \
  -H "Authorization: Bearer $NOXTICA_API_KEY"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/status', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
  },
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/status",
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
    },
    method="GET",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Read a batch job's per-item outcomes {#kycVerifyBatchResults}

`GET /v1/kyc/verify/batch/{batchId}/results`

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `batchId` | path | string | Yes |  |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Every row's outcome, malformed or not — never silently dropped. |  | `application/json` KycBatchResultsResponse |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `batchId` | string | No |  |
| `items` | array of object | No |  |
| `items[]` | object | No |  |
| `items[].rowIndex` | integer | No |  |
| `items[].status` | string | No | Allowed values: `pending`, `admitted`, `evaluating`, `completed`, `rejected`, `cancelled`. |
| `items[].verificationId` | string, nullable | No |  |
| `items[].verdict` | string, nullable | No | Allowed values: `verified`, `unverified`, `error`. |
| `items[].errorCode` | one of KycBatchItemErrorCode or any, nullable | No |  |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "batchId": "batch_5a1f9c3e7b2d4a6f8091c2d3e4f5a6b7",
  "items": [
    {
      "rowIndex": 0,
      "status": "pending",
      "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "verdict": "verified",
      "errorCode": "missing_id_type"
    }
  ],
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl "https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/results" \
  -H "Authorization: Bearer $NOXTICA_API_KEY"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/results', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
  },
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/results",
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
    },
    method="GET",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Cancel every still-pending item of a batch {#kycVerifyBatchCancel}

`POST /v1/kyc/verify/batch/{batchId}/cancel`

A compare-and-swap `UPDATE ... WHERE status = 'pending'`. An item already `admitted`/`evaluating`/`completed` already holds (or held) a real verification claim and is never retroactively un-admitted.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `batchId` | path | string | Yes |  |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Cancellation applied to every still-pending row. |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `batchId` | string | No |  |
| `cancelledCount` | integer | No |  |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "batchId": "batch_5a1f9c3e7b2d4a6f8091c2d3e4f5a6b7",
  "cancelledCount": 0,
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/cancel" \
  -H "Authorization: Bearer $NOXTICA_API_KEY"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/cancel', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
  },
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/verify/batch/example-batchId/cancel",
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Re-check an already-established identity's device/verification level {#kycReverify}

`POST /v1/kyc/reverify`

A metered, read-shaped check against the authoritative device registry — it does not re-run document/biometric capture. Requires the device-registry module to be enabled for your organisation in addition to the KYC module; when it is not, the route answers the `GatedNotFound` 404.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subjectId` | string | Yes |  |
| `requiredLevel` | string | No | Allowed values: `verified`, `partial`, `unverified`, `inconclusive`. |
| `stepUpReference` | string | No | Optional biometric step-up claim reference (single-use). At most 128 characters. Pattern `^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Always 200 (FR-6 fail-closed discipline) — a subject-not-found or infra fault is a `verified:false` body, never a 5xx. |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `verified` | boolean | No |  |
| `verificationLevel` | string | No | Allowed values: `verified`, `partial`, `unverified`, `inconclusive`. |
| `stepUp` | string | No | Allowed values: `satisfied`, `unconfigured`, `reference_required`, `reference_replayed`, `no_match`, `error`. |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "verified": true,
  "verificationLevel": "verified",
  "stepUp": "satisfied",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/reverify" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "subjectId": "subj_4821",
  "requiredLevel": "verified",
  "stepUpReference": "example-stepUpReference"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/reverify', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    subjectId: 'subj_4821',
    requiredLevel: 'verified',
    stepUpReference: 'example-stepUpReference',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/reverify",
    data=json.dumps({
        "subjectId": "subj_4821",
        "requiredLevel": "verified",
        "stepUpReference": "example-stepUpReference",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Mint a single-use ciphertext-upload handle {#kycEvidenceHandleMint}

`POST /v1/kyc/evidence/handle`

Step 1 of 2 for raw evidence storage. Mints an opaque, short-lived (15-minute) upload handle plus a fresh per-object Data Encryption Key (DEK) wrapped under the tenant's active Key Encryption Key (KEK). The caller encrypts the evidence bytes client-side with the returned `dek` and presents only ciphertext to `POST /v1/kyc/evidence/finalize` — Noxtica's server never sees plaintext evidence at this step. No R2 storage URL, internal key, or `sk_` value is ever returned (D27).

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `verificationId` | string (uuid) | Yes |  |
| `jurisdiction` | string | Yes | Must equal the tenant's configured evidence residency; any mismatch is a 404 (no residency-probe oracle). Allowed values: `TR`, `EU`, `US`. |
| `evidenceKind` | string | Yes | Closed evidence-kind catalog (this enum is the complete set). e.g. id_document_front, proof_of_address, selfie_image. NOTE: id_front/id_back/selfie are NOT members of this catalog (a prior stale example here caused exactly that drift in downstream SDKs). Allowed values: `id_document_front`, `id_document_back`, `document_front`, `document_back`, `passport_page`, `proof_of_address`, `selfie_image`, `document_near_face`, `liveness_video`, `liveness_recording`, `liveness_sequence`, `voice_sample`, `speech_audio`, `supporting_document`, `document_data`. |
| `purpose` | string | Yes | Closed evidence-purpose catalog (e.g. identity_verification, address_verification). |
| `contentType` | string | Yes |  |
| `contentLength` | integer | Yes | Minimum 1. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Handle minted. |  | `application/json` |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `413` | `body_too_large` — the request exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | Evidence storage temporarily unavailable (handle secret or KEK unresolved). | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `handle` | string | No | Opaque signed single-use upload handle. |
| `dek` | string | No | Base64 plaintext DEK — hold only in memory |
| `wrappedDek` | string | No | The same DEK wrapped under the active KEK |
| `envelope` | object | No |  |
| `envelope.envelope_version` | integer | No |  |
| `envelope.cipher_suite` | string | No |  |
| `envelope.kek_kid` | string | No |  |
| `uploadTarget` | object | No |  |
| `uploadTarget.objectId` | string | No |  |
| `uploadTarget.schemaVersion` | integer | No |  |
| `expiresAt` | integer | No | Unix ms handle expiry. |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "handle": "example-handle",
  "dek": "example-dek",
  "wrappedDek": "example-wrappedDek",
  "envelope": {
    "envelope_version": 1,
    "cipher_suite": "example-cipher_suite",
    "kek_kid": "example-kek_kid"
  },
  "uploadTarget": {
    "objectId": "example-objectId",
    "schemaVersion": 1
  },
  "expiresAt": 1768435200,
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/evidence/handle" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "jurisdiction": "TR",
  "evidenceKind": "id_document_front",
  "purpose": "identity_verification",
  "contentType": "image/jpeg",
  "contentLength": 1
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/evidence/handle', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    verificationId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
    jurisdiction: 'TR',
    evidenceKind: 'id_document_front',
    purpose: 'identity_verification',
    contentType: 'image/jpeg',
    contentLength: 1,
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/evidence/handle",
    data=json.dumps({
        "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "jurisdiction": "TR",
        "evidenceKind": "id_document_front",
        "purpose": "identity_verification",
        "contentType": "image/jpeg",
        "contentLength": 1,
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Upload sealed ciphertext and finalize the evidence object {#kycEvidenceFinalize}

`POST /v1/kyc/evidence/finalize`

Step 2 of 2. Presents the handle from `POST /v1/kyc/evidence/handle` plus the chunked-AEAD-sealed ciphertext body. The server performs a controlled in-memory decrypt → structural inspection (never persisting plaintext) and promotes the object to `usable` or `rejected`. A repeated call with the same handle/body replays the stored terminal result byte-for-byte (idempotent by construction, not by an `Idempotency-Key` header).

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `handle` | string | Yes | The opaque handle from the mint step. |
| `objectId` | string | Yes |  |
| `commitment` | object | Yes |  |
| `commitment.total_chunks` | integer | Yes | Minimum 1. |
| `commitment.total_plaintext_length` | integer | Yes | Minimum 0. |
| `chunks` | array of string | Yes | Chunked-AEAD-sealed ciphertext chunks (schema version carried in the mint-step envelope). |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Terminal inspection outcome. |  | `application/json` |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented handle failed verification (signature, expiry, manifest, or binding mismatch) — a single uniform refusal, no probe oracle. | `invalid_request` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `413` | `body_too_large` — the request exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | Evidence storage temporarily unavailable. | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `state` | string | Yes | Allowed values: `usable`, `rejected`. |
| `reasonCode` | string | No | Present only when `state` is `rejected`. Allowed values: `evidence_kind_forbidden`, `evidence_mime_mismatch`, `evidence_size_mismatch`, `evidence_pixels_invalid`, `evidence_unreadable`. |
| `objectId` | string | Yes |  |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "state": "usable",
  "reasonCode": "evidence_kind_forbidden",
  "objectId": "example-objectId",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/evidence/finalize" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "handle": "example-handle",
  "objectId": "example-objectId",
  "commitment": {
    "total_chunks": 1,
    "total_plaintext_length": 0
  },
  "chunks": [
    "example-chunks"
  ]
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/evidence/finalize', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    handle: 'example-handle',
    objectId: 'example-objectId',
    commitment: {
      total_chunks: 1,
      total_plaintext_length: 0,
    },
    chunks: [
      'example-chunks',
    ],
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/evidence/finalize",
    data=json.dumps({
        "handle": "example-handle",
        "objectId": "example-objectId",
        "commitment": {
            "total_chunks": 1,
            "total_plaintext_length": 0,
        },
        "chunks": [
            "example-chunks",
        ],
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Mint a Noxtica-hosted KYC verification-session link {#kycSessionMint}

`POST /v1/kyc/sessions`

Server-to-server. Creates a hosted verification session and returns a one-shot,
opaque URL to hand to your end customer (redirect, email, SMS or QR). The URL embeds a
bearer session token that is never returned again by any route: treat it as a secret,
do not log it and do not put it in an analytics URL. Minting is free; the verification is
counted when the customer submits.

**Before you can mint.** (1) The identity verification module must be enabled for your organisation;
until it is, this route answers the same byte-identical `404` as an unknown path. (2) Your
`sk_` key needs the `write:identity` scope (a key created with the default scope is
refused with `403`). (3) A tenant administrator must have recorded an active legal-basis
receipt for hosted document capture in the Backoffice; without it the mint is refused with
`403 kyc_legal_basis_receipt_required`. Noxtica never selects a legal basis for you. (4)
Your organisation needs a hosted configuration (business name, privacy URL, supported
documents) in the Backoffice, or the customer sees "Verification isn't ready yet"; the mint
itself does not check this.

**Request.** `subjectRef` is required. The expiry field is `expiresInSeconds`: a key named
`ttlSeconds` is not read and is silently ignored (the link then lasts the 24 hour default).
Unknown keys are ignored. The snake_case spellings `subject_ref`, `return_url`,
`id_type`, `expires_in_seconds` and `hosted_options` are also accepted; the camelCase names
are the contract. Locale, customer name, email and phone are not request fields: the page
language follows the customer's browser and cannot be forced, and the webhook endpoint is
registered once for your organisation in the Backoffice, not per session.

**Lifetime.** An unopened link keeps `expiresInSeconds` (default 24 hours). Once the
customer first opens it the deadline tightens to `min(expiresAt, openedAt + 30 minutes)`;
the customer must finish capture inside that window. `expiresAt` in every response is
Unix time in **milliseconds**.

**Using the link.** The hosted page cannot be embedded in an iframe (`X-Frame-Options:
DENY`); open it in a new tab, or redirect the customer to it. The page never posts a
message to the opener or parent window and never redirects on its own. If you minted with
a `returnUrl`, the page shows a plain "Return to &lt;business>" link to exactly that URL
(nothing is appended) on the completion and status screens only for a completed outcome,
and on the screen shown when a step cannot continue. Sessions created from Backoffice
invitations carry no `returnUrl`. Correlate by the `sessionId` you stored at mint, and
never treat a visit to your return page as proof of completion.

**Outcome.** Learn the outcome with the `kyc.session.completed` webhook or by polling
`GET /v1/kyc/sessions/{sessionId}`. With the identity provider configured today every
successful capture ends in `review_pending`, not `completed`: the webhook fires only
after a Backoffice reviewer approves the case (allow up to about 5 minutes after the
decision), and a `review_pending` session never expires. See that route for the
status table.

**Email verification (optional step).** `hostedOptions.steps.emailVerification`
(`off`, `optional` or `required`) adds an email-verification step to the hosted flow: the
customer types an address, receives one confirmation link, and only a click on that link
marks the address verified. Omit the key to use your organisation's default (Hosted
configuration); an explicit value for this link wins in both directions. The effective
mode is resolved once, here, and is fixed for the session. This is available only once the
platform has enabled hosted email verification: until then an explicit `optional` or
`required` is refused with `400 email_verification_unavailable` (an explicit `off` is
always accepted, and your organisation default is ignored), so a `required` session can
never silently run unverified.

**Idempotency.** `Idempotency-Key` is optional (8 to 128 characters). Without it every
call mints a fresh session. With it, a retry under the same key returns the SAME session
with `replayed: true` and WITHOUT the `url`: the one-shot URL of a lost response cannot be
recovered, so mint a new session under a new key instead. The replay matches on the key
only; a different body under the same key still returns the original session.

**Rate limits.** A per-source-IP limit applies first, before authentication, then a per-key limit
shared with the status read. See "Rate limits" above.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | No | When supplied, a replayed key returns the SAME minted session (WITHOUT re-exposing its one-shot URL) instead of a fresh mint. Omit entirely for a plain, always-fresh mint. At least 8 characters, at most 128 characters. |

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `subjectRef` | string | Yes | Your own opaque reference for this end customer (trimmed, non-empty). It is returned to you as `subjectId` on the status read and is the identifier an erasure request targets. It is never sent in webhooks. At least 1 character, at most 256 characters. |
| `returnUrl` | string (uri) | No | Where the "Return to &lt;business>" link on the hosted page points. `https` only, no user info, at most 2048 characters, and the host must exactly equal (case insensitive) an entry in your organisation's approved return hosts (`allowedReturnUrlHosts` in the Backoffice hosted configuration; at most 20 entries, host names only, no wildcards; port, path and query are not checked). An organisation with no approved hosts cannot send a `returnUrl`. Anything else is refused with `400 return_url_not_allowed`. The page appends nothing to it. At most 2048 characters. Pattern `^https://`. |
| `purpose` | string | No | Shown to the customer on the consent screen. Falls back to your organisation's default purpose. More than 500 characters is refused with `400 invalid_request`. At most 500 characters. |
| `idType` | string | No | Document type hint; it must be one of `hostedOptions.allowedDocumentTypes` (default: the first allowed type, `national_id` when `hostedOptions` is omitted), else `400 invalid_id_type`. The customer still chooses the type and issuing country on the page from the allowed types your organisation configured. Allowed values: `national_id`, `passport`, `driving_license`, `residence_permit`. |
| `expiresInSeconds` | integer | No | Lifetime of the UNOPENED link in seconds: 5 minutes to 7 days, default 24 hours. A value outside the range or a non-integer is refused with `400 invalid_request` (it is not clamped). After the customer opens the link the 30 minute capture window applies. Minimum 300, maximum 604800. Default `86400`. |
| `hostedOptions` | object (KycHostedSessionOptions) | No | Invitation-scoped hosted-flow switches, frozen into the session at mint. When you send `hostedOptions` it must be the complete object (every required key below, no merging with defaults); omit it to use the defaults (all four document types, `allowDeviceUpload` false, `documentBack` `auto`, `documentNearFace` false, `activeLiveness` true, `speechAnalysis` false, `ageGenderEstimation` false, `emailVerification` `off`). Inside `steps`, `ageGenderEstimation` and `emailVerification` are optional. |
| `hostedOptions.allowedDocumentTypes` | array of string | Yes | At least 1 item, at most 4 items, unique items. |
| `hostedOptions.allowDeviceUpload` | boolean | Yes |  |
| `hostedOptions.steps` | object | Yes |  |
| `hostedOptions.steps.documentBack` | string | Yes | Allowed values: `auto`. |
| `hostedOptions.steps.documentNearFace` | boolean | Yes |  |
| `hostedOptions.steps.activeLiveness` | boolean | Yes |  |
| `hostedOptions.steps.speechAnalysis` | boolean | Yes |  |
| `hostedOptions.steps.ageGenderEstimation` | boolean | No | Optional; default `false`. Adds an advisory age and gender estimate from the selfie frames (biometric, special-category data); the customer gives a separate consent on the page. Setting `true` is refused with `403 age_gender_estimation_not_enabled` (`reasonCode` `age_gender_platform_disabled` or `age_gender_tenant_acknowledgement_missing`) unless the platform has enabled the feature AND your organisation has recorded the matching legal-basis acknowledgement in the Backoffice. It never fails a verification. |
| `hostedOptions.steps.emailVerification` | string | No | Optional (added 2026-10-01; default `off` when the key is absent). When omitted from a mint request your organisation's default applies; an explicit value wins in both directions. An explicit `optional` or `required` is refused with `400 email_verification_unavailable` until the platform has enabled hosted email verification. The mint response echoes the effective mode. Allowed values: `off`, `optional`, `required`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Session minted, or (idempotent replay) the existing session returned without its URL. |  | `application/json` |
| `400` | `invalid_request` (unreadable JSON, `Idempotency-Key` outside 8 to 128 characters, `subjectRef` missing or longer than 256, `expiresInSeconds` outside 300 to 604800 or not an integer, `purpose` longer than 500, a bad `payload`), `invalid_id_type`, `invalid_hosted_options` (`hostedOptions` is not a complete, well-formed object, or `steps.emailVerification` is not one of `off`, `optional`, `required`), `return_url_not_allowed`, or `email_verification_unavailable` (an explicit `steps.emailVerification` of `optional` or `required` was requested but hosted email verification is not enabled for the platform). | `email_verification_unavailable`, `invalid_hosted_options`, `invalid_id_type`, `invalid_request`, `return_url_not_allowed` | `application/json` KycSessionError |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The key lacks the `write:identity` scope; or `kyc_legal_basis_receipt_required` (no active hosted-capture legal-basis receipt for your organisation); or `age_gender_estimation_not_enabled` (`hostedOptions.steps.ageGenderEstimation` was `true` but the platform feature or your organisation's acknowledgement is missing; see `reasonCode`). | `age_gender_estimation_not_enabled`, `kyc_legal_basis_receipt_required` | `application/json` KycSessionError |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `413` | `body_too_large` — the request body exceeds 32768 bytes. | `body_too_large` | `application/json` KycSessionError |
| `415` | The request is not `Content-Type: application/json` (body `invalid_request`). | `invalid_request` | `application/json` KycSessionError |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | API-key authentication is not configured on this deployment (`server_misconfigured`). Retrying does not help; contact support with the `requestId`. | `server_misconfigured` | `application/json` KycSessionError |
| `503` | Retryable unless noted. `tenant_module_gate_unavailable` (the module gate could not be answered; `Retry-After: 2`), `kyc_legal_basis_receipt_unavailable` (the receipt check faulted; `reason`, `Retry-After: 2`), `hosted_verify_not_configured` (the hosted deployment is not configured; contact support, no `Retry-After`), or `invalid_request` with a `reason` (a database or backend fault; `Retry-After: 2`). Retry a `503` with the SAME `Idempotency-Key`. | `invalid_request` | `application/json` KycSessionError |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `sessionId` | string (uuid) | Yes | The session id. Store it with your customer record; it is the correlation handle for status reads and webhooks. |
| `url` | string (uri) | No | One-shot hosted-flow URL; the ONLY place the bearer token is ever returned. Absent on an idempotent replay. |
| `expiresAt` | integer (int64) | Yes | Unix time in milliseconds at which the unopened link expires. |
| `replayed` | boolean | No | Present (and `true`) only when an `Idempotency-Key` matched an existing session; `url` is then absent. Allowed values: `true`. |
| `hostedOptions` | object | No | The effective hosted options stored with the session, including the resolved `steps.emailVerification` mode (your organisation default applied when the request did not set it). Present only when hosted email verification is enabled for the platform AND the session's effective mode is not `off`; absent otherwise (a session that does not use the step gets the pre-feature response). |
| `hostedOptions.allowedDocumentTypes` | array of string | Yes | At least 1 item, at most 4 items, unique items. |
| `hostedOptions.allowDeviceUpload` | boolean | Yes |  |
| `hostedOptions.steps` | object | Yes |  |
| `hostedOptions.steps.documentBack` | string | Yes | Allowed values: `auto`. |
| `hostedOptions.steps.documentNearFace` | boolean | Yes |  |
| `hostedOptions.steps.activeLiveness` | boolean | Yes |  |
| `hostedOptions.steps.speechAnalysis` | boolean | Yes |  |
| `hostedOptions.steps.ageGenderEstimation` | boolean | No | Optional; default `false`. Adds an advisory age and gender estimate from the selfie frames (biometric, special-category data); the customer gives a separate consent on the page. Setting `true` is refused with `403 age_gender_estimation_not_enabled` (`reasonCode` `age_gender_platform_disabled` or `age_gender_tenant_acknowledgement_missing`) unless the platform has enabled the feature AND your organisation has recorded the matching legal-basis acknowledgement in the Backoffice. It never fails a verification. |
| `hostedOptions.steps.emailVerification` | string | No | Optional (added 2026-10-01; default `off` when the key is absent). When omitted from a mint request your organisation's default applies; an explicit value wins in both directions. An explicit `optional` or `required` is refused with `400 email_verification_unavailable` until the platform has enabled hosted email verification. The mint response echoes the effective mode. Allowed values: `off`, `optional`, `required`. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "sessionId": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21",
  "url": "https://verify.noxtica.com/s/kycs.tenant_42.q3JkT0vN8mYb1xW6cE4aZ7uP2sH9dLfR5gKiA0oXyUw",
  "expiresAt": 1790765600000,
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/sessions" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "subjectRef": "cust_4821",
  "returnUrl": "https://merchant.example.com/kyc/return",
  "purpose": "Open your brokerage account",
  "idType": "passport",
  "expiresInSeconds": 3600,
  "hostedOptions": {
    "allowedDocumentTypes": [
      "passport",
      "national_id"
    ],
    "allowDeviceUpload": false,
    "steps": {
      "documentBack": "auto",
      "documentNearFace": false,
      "activeLiveness": true,
      "speechAnalysis": false,
      "ageGenderEstimation": false
    }
  }
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/sessions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    subjectRef: 'cust_4821',
    returnUrl: 'https://merchant.example.com/kyc/return',
    purpose: 'Open your brokerage account',
    idType: 'passport',
    expiresInSeconds: 3600,
    hostedOptions: {
      allowedDocumentTypes: [
        'passport',
        'national_id',
      ],
      allowDeviceUpload: false,
      steps: {
        documentBack: 'auto',
        documentNearFace: false,
        activeLiveness: true,
        speechAnalysis: false,
        ageGenderEstimation: false,
      },
    },
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/sessions",
    data=json.dumps({
        "subjectRef": "cust_4821",
        "returnUrl": "https://merchant.example.com/kyc/return",
        "purpose": "Open your brokerage account",
        "idType": "passport",
        "expiresInSeconds": 3600,
        "hostedOptions": {
            "allowedDocumentTypes": [
                "passport",
                "national_id",
            ],
            "allowDeviceUpload": False,
            "steps": {
                "documentBack": "auto",
                "documentNearFace": False,
                "activeLiveness": True,
                "speechAnalysis": False,
                "ageGenderEstimation": False,
            },
        },
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Read a hosted session's current status (tenant backend read) {#kycSessionStatus}

`GET /v1/kyc/sessions/{sessionId}`

Server-to-server. The authoritative tenant-side read of a hosted session; use it to
confirm any webhook before you act on it, and to poll when you have no webhook endpoint.
Everything is nested under `session`, and every timestamp is Unix time in
**milliseconds**. A session that is past its deadline reads `200` with
`status: "expired"` (never `410`; the `410` answers belong to the customer's browser
routes). A session id that does not exist, or belongs to another organisation, gets the
same byte-identical `404` as an unknown path.

**Status lifecycle.**

| `status` | Meaning | `kyc.session.*` webhook |
|---|---|---|
| `pending` | Minted; the customer has not opened the link. | none |
| `opened` | The customer reached the capture page. | none |
| `verifying` | The customer submitted; the verification is running. | none |
| `review_pending` | The verification finished and awaits a Backoffice review decision. With the identity provider configured today every successful hosted capture lands here. It never expires. | **none yet**; it fires later as `completed` |
| `completed` | A business decision exists: a reviewer approved or overrode the case (or, by design, a machine `verified`). | `kyc.session.completed` with `status: completed` |
| `unverified` | Finished with a negative outcome and no manual-review code (for example capture attempts exhausted). | `kyc.session.completed` with `status: unverified` |
| `error` | A provider or infrastructure fault, or the submit was refused at admission. | `kyc.session.completed` with `status: error` |
| `expired` | Past the deadline without a decision. | never |
| `cancelled_by_user` | The customer cancelled on the hosted page (only when cancellation is enabled for your organisation). | `kyc.session.cancelled` |

**`review_pending` to `completed`.** `kyc.session.completed` does not fire when a session
lands in `review_pending`. It fires when a Backoffice reviewer approves or overrides the
case; allow up to about 5 minutes between the decision and the delivery. Until then the
only way to observe `review_pending` is this read. A rejected end state is not currently
expressible by the review vocabulary.

**Cancellation.** `status: cancelled_by_user` is reported here, but `cancelledAt` and
`cancelReason` are NOT part of this response; they are delivered in the
`kyc.session.cancelled` webhook. An expiry emits no webhook at all. A cancelled or expired
session is never billed as a verification (the `kyc.verification` meter is recorded only
at submit-time admission); only the request-level `endpoint.collect.request` counts.

**Email verification result.** Only when hosted email verification is enabled for the
platform AND the session's effective mode is not `off` or an address is stored, the
`session` object also carries `email` (the normalised address the customer entered, or
`null`), `emailVerified` (`true` only after the customer clicked the confirmation link for
that exact address), `emailVerifiedAt` (Unix ms, or `null`) and `emailVerification` (the
effective mode: `off`, `optional` or `required`). Treat `email` with `emailVerified: false`
as an unverified, customer-typed value. Otherwise these four keys are absent.

**Noxtica Verification ID.** Only when the Noxtica Verification ID (NVID) is enabled for
the platform, the `session` object also carries `nvid`: the session's NVID
(`NVID-XXXX-XXXX-XXXX-XXXX`) or `null` (none was issued: an NVID is issued when the
submit ends `completed` or `review_pending`). The key is absent while the feature is not
enabled. The public status page of an NVID is `https://verify.<environment host>/v/<NVID>`;
the public lookup route behind it can only be called from the hosted site's browser
origin, never from a tenant backend.

**Rate limit.** Shares the per-key limit (30 requests per 60 seconds) with the mint call,
after the per-source-IP limit that applies to every request.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sessionId` | path | string | Yes | The `sessionId` returned by the mint call. Pattern `^[A-Za-z0-9-]{1,64}$`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The session's current status. |  | `application/json` KycTenantSessionEnvelope |
| `400` | `Invalid sessionId` — the path segment is not valid percent-encoding. | `Invalid sessionId` | `application/json` KycSessionError |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `500` | API-key authentication is not configured on this deployment (`server_misconfigured`). Retrying does not help; contact support with the `requestId`. | `server_misconfigured` | `application/json` KycSessionError |
| `503` | Retryable (`Retry-After: 2`). `tenant_module_gate_unavailable`, or `invalid_request` with a `reason` (`pg_unavailable` or `backend_unavailable`). | `invalid_request` | `application/json` KycSessionError |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `session` | object (KycTenantSessionStatus) | Yes | The tenant-backend view of a hosted session. Every timestamp is Unix time in **milliseconds**. Not exposed on this route: the session URL or token, evidence bytes or URLs, the stored `hostedOptions`, `cancelledAt` and `cancelReason` (those are delivered only in the `kyc.session.cancelled` webhook), model provenance, and the address a person may have given to receive their Noxtica Verification ID. |
| `session.id` | string (uuid) | Yes | The session id returned by the mint call. |
| `session.status` | string | Yes | The hosted session lifecycle status. `pending` — minted, link not opened yet. `opened` — the customer reached the capture page. `verifying` — the customer submitted and the verification is running. `review_pending` — the verification finished and awaits a Backoffice review decision (today every successful hosted capture lands here; it never expires, and `kyc.session.completed` has NOT fired yet). `completed` — a business decision exists. `unverified` — finished with a negative outcome and no manual-review code. `error` — a provider or infrastructure fault, or the submit was refused at admission. `expired` — the effective deadline passed without a decision (reported by the status read; no webhook). `cancelled_by_user` — the customer cancelled on the hosted page. Allowed values: `pending`, `opened`, `verifying`, `review_pending`, `completed`, `unverified`, `error`, `expired`, `cancelled_by_user`. |
| `session.idType` | string | Yes | The document type hint given at mint (default `national_id`). |
| `session.subjectId` | string | Yes | Your own `subjectRef` from the mint call, returned unchanged. It is never sent in webhooks. |
| `session.captureStep` | string | Yes | The furthest capture step the customer has reached. Allowed values: `consent`, `id_front`, `id_back`, `selfie`, `document_near_face`, `liveness`, `voice`, `review`, `submit`. |
| `session.createdAt` | integer (int64) | Yes |  |
| `session.openedAt` | integer (int64), nullable | Yes | When the customer first opened the link; `null` until then. |
| `session.expiresAt` | integer (int64) | Yes | The effective deadline. Equal to the mint-time deadline while the link is unopened; once the customer opens it, tightened to `min(expiresAt, openedAt + 1800000)` (the 30-minute capture window). A session in `review_pending` does not expire. |
| `session.completedAt` | integer (int64), nullable | Yes | When the decision was made (pipeline outcome, or the reviewer's decision for a session that was `review_pending`); `null` before. |
| `session.verificationId` | string, nullable | Yes | The verification the submit ran (also `data.verificationId` of `kyc.verification.completed`); `null` before the customer submits. |
| `session.reasonCodes` | array of string | Yes | Closed reason-code catalog. `provider_manual_review` means the case is waiting for a manual review. |
| `session.purpose` | string, nullable | Yes | The mint-time `purpose`. |
| `session.returnUrl` | string, nullable | Yes | The mint-time `returnUrl`. |
| `session.selectedIdType` | string, nullable | Yes | The document type the customer chose on the page. |
| `session.selectedIssuingCountry` | string, nullable | Yes | The issuing country the customer chose. |
| `session.consentVersion` | string, nullable | Yes | Version of the privacy notice the customer acknowledged. |
| `session.consentAcceptedAt` | integer (int64), nullable | Yes |  |
| `session.speechProfilingConsentAcceptedAt` | integer (int64), nullable | Yes |  |
| `session.ageGenderConsentAcceptedAt` | integer (int64), nullable | Yes |  |
| `session.evidence` | object (KycSessionEvidenceRefs) | Yes | Opaque object ids of the finalized (usable) uploads, by role. Never a URL or media bytes. A role appears only once its upload is finalized. |
| `session.evidence.front` | string | No |  |
| `session.evidence.back` | string | No |  |
| `session.evidence.selfie` | string | No |  |
| `session.evidence.voice` | string | No |  |
| `session.evidence.documentNearFace` | string | No |  |
| `session.decision` | object (KycSessionDecision) | No | The decision block, present once the pipeline or a review decision has produced one. `outcome` maps from the session status: `completed` to `approved`, `unverified` to `rejected`, `review_pending` to `review_pending`, `error` to `error`. |
| `session.decision.outcome` | string | Yes | Allowed values: `approved`, `rejected`, `review_pending`, `error`. |
| `session.decision.reasonCodes` | array of string | Yes |  |
| `session.decision.decidedAt` | integer (int64) | Yes | Unix ms. |
| `session.crossValidation` | object | No | The OCR / MRZ / barcode cross-validation advisory block, when the pipeline attached one. Absent for sessions without back-of-document evidence. Verdict-neutral. |
| `session.stages` | object (KycTenantStages) | Yes | Per-stage results keyed by stage name. A stage that was not requested or has not run reads `status: skipped` or `pending` with a reason code, never a fabricated pass. |
| `session.stages.scene_inventory` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.scene_inventory.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.scene_inventory.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.scene_inventory.reasonCodes` | array of string | Yes |  |
| `session.stages.document_type_id` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.document_type_id.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.document_type_id.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.document_type_id.reasonCodes` | array of string | Yes |  |
| `session.stages.ocr_back` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.ocr_back.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.ocr_back.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.ocr_back.reasonCodes` | array of string | Yes |  |
| `session.stages.mrz_crosscheck` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.mrz_crosscheck.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.mrz_crosscheck.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.mrz_crosscheck.reasonCodes` | array of string | Yes |  |
| `session.stages.selfie_quality` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.selfie_quality.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.selfie_quality.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.selfie_quality.reasonCodes` | array of string | Yes |  |
| `session.stages.document_near_face` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.document_near_face.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.document_near_face.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.document_near_face.reasonCodes` | array of string | Yes |  |
| `session.stages.active_liveness` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.active_liveness.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.active_liveness.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.active_liveness.reasonCodes` | array of string | Yes |  |
| `session.stages.speech_profile` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.speech_profile.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.speech_profile.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.speech_profile.reasonCodes` | array of string | Yes |  |
| `session.stages.speech_analysis` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.speech_analysis.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.speech_analysis.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.speech_analysis.reasonCodes` | array of string | Yes |  |
| `session.stages.age_gender` | object (KycTenantStageResult) | No | One analysis stage's tenant-facing result. Stages that carry extra advisory detail (`speech_profile`, `age_gender`, `scene_inventory`, `document_type_id`) add typed members next to these three; model and engine provenance is never exposed. |
| `session.stages.age_gender.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.stages.age_gender.score` | number, nullable | Yes | Range 0 to 1, or `null` when the stage produced no score. |
| `session.stages.age_gender.reasonCodes` | array of string | Yes |  |
| `session.scorecard` | object (KycTenantScorecard) | Yes | The numeric per-step summary. A non-authoritative roll-up of what the pipeline already decided: never an approval by itself. `overall` is the roll-up of the other six. |
| `session.scorecard.document_front` | object (KycScorecardEntry) | No |  |
| `session.scorecard.document_front.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.scorecard.document_front.score` | number | Yes | Range 0 to 1; `0` for `pending` and `skipped` (no evaluation occurred). |
| `session.scorecard.document_front.checks` | array of object | Yes |  |
| `session.scorecard.document_front.checks[]` | object | No |  |
| `session.scorecard.document_front.checks[].name` | string | Yes |  |
| `session.scorecard.document_front.checks[].pass` | boolean | Yes |  |
| `session.scorecard.document_front.checks[].score` | number, nullable | No |  |
| `session.scorecard.document_front.reasonCodes` | array of string | Yes |  |
| `session.scorecard.document_back` | object (KycScorecardEntry) | No |  |
| `session.scorecard.document_back.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.scorecard.document_back.score` | number | Yes | Range 0 to 1; `0` for `pending` and `skipped` (no evaluation occurred). |
| `session.scorecard.document_back.checks` | array of object | Yes |  |
| `session.scorecard.document_back.checks[]` | object | No |  |
| `session.scorecard.document_back.checks[].name` | string | Yes |  |
| `session.scorecard.document_back.checks[].pass` | boolean | Yes |  |
| `session.scorecard.document_back.checks[].score` | number, nullable | No |  |
| `session.scorecard.document_back.reasonCodes` | array of string | Yes |  |
| `session.scorecard.selfie` | object (KycScorecardEntry) | No |  |
| `session.scorecard.selfie.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.scorecard.selfie.score` | number | Yes | Range 0 to 1; `0` for `pending` and `skipped` (no evaluation occurred). |
| `session.scorecard.selfie.checks` | array of object | Yes |  |
| `session.scorecard.selfie.checks[]` | object | No |  |
| `session.scorecard.selfie.checks[].name` | string | Yes |  |
| `session.scorecard.selfie.checks[].pass` | boolean | Yes |  |
| `session.scorecard.selfie.checks[].score` | number, nullable | No |  |
| `session.scorecard.selfie.reasonCodes` | array of string | Yes |  |
| `session.scorecard.liveness` | object (KycScorecardEntry) | No |  |
| `session.scorecard.liveness.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.scorecard.liveness.score` | number | Yes | Range 0 to 1; `0` for `pending` and `skipped` (no evaluation occurred). |
| `session.scorecard.liveness.checks` | array of object | Yes |  |
| `session.scorecard.liveness.checks[]` | object | No |  |
| `session.scorecard.liveness.checks[].name` | string | Yes |  |
| `session.scorecard.liveness.checks[].pass` | boolean | Yes |  |
| `session.scorecard.liveness.checks[].score` | number, nullable | No |  |
| `session.scorecard.liveness.reasonCodes` | array of string | Yes |  |
| `session.scorecard.voice` | object (KycScorecardEntry) | No |  |
| `session.scorecard.voice.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.scorecard.voice.score` | number | Yes | Range 0 to 1; `0` for `pending` and `skipped` (no evaluation occurred). |
| `session.scorecard.voice.checks` | array of object | Yes |  |
| `session.scorecard.voice.checks[]` | object | No |  |
| `session.scorecard.voice.checks[].name` | string | Yes |  |
| `session.scorecard.voice.checks[].pass` | boolean | Yes |  |
| `session.scorecard.voice.checks[].score` | number, nullable | No |  |
| `session.scorecard.voice.reasonCodes` | array of string | Yes |  |
| `session.scorecard.cross_validation` | object (KycScorecardEntry) | No |  |
| `session.scorecard.cross_validation.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.scorecard.cross_validation.score` | number | Yes | Range 0 to 1; `0` for `pending` and `skipped` (no evaluation occurred). |
| `session.scorecard.cross_validation.checks` | array of object | Yes |  |
| `session.scorecard.cross_validation.checks[]` | object | No |  |
| `session.scorecard.cross_validation.checks[].name` | string | Yes |  |
| `session.scorecard.cross_validation.checks[].pass` | boolean | Yes |  |
| `session.scorecard.cross_validation.checks[].score` | number, nullable | No |  |
| `session.scorecard.cross_validation.reasonCodes` | array of string | Yes |  |
| `session.scorecard.overall` | object (KycScorecardEntry) | No |  |
| `session.scorecard.overall.status` | string | Yes | Allowed values: `pass`, `review`, `fail`, `skipped`, `pending`. |
| `session.scorecard.overall.score` | number | Yes | Range 0 to 1; `0` for `pending` and `skipped` (no evaluation occurred). |
| `session.scorecard.overall.checks` | array of object | Yes |  |
| `session.scorecard.overall.checks[]` | object | No |  |
| `session.scorecard.overall.checks[].name` | string | Yes |  |
| `session.scorecard.overall.checks[].pass` | boolean | Yes |  |
| `session.scorecard.overall.checks[].score` | number, nullable | No |  |
| `session.scorecard.overall.reasonCodes` | array of string | Yes |  |
| `session.email` | string, nullable | No | Present only when hosted email verification is enabled for your organisation AND (the session's effective `emailVerification` mode is not `off` OR an address is stored). The normalised address the customer entered, or `null`. It is the customer's own typed value until `emailVerified` is `true`. |
| `session.emailVerified` | boolean | No | Present under the same condition as `email`. `true` only after the customer clicked the confirmation link for that exact address. |
| `session.emailVerifiedAt` | integer (int64), nullable | No | Present under the same condition as `email`. When the confirmation link was clicked, or `null`. |
| `session.emailVerification` | string | No | Hosted email-verification mode. `off` — no email step. `optional` — the customer is offered the step and may skip it. `required` — the customer must verify an address before the session can be submitted (`409 email_verification_required` until then). The mode is resolved once at mint and fixed for the session. On the tenant-facing `GET /v1/kyc/sessions/{sessionId}` this key is the effective mode string; the hosted page's own session routes carry the customer-facing state object `KycEmailVerificationState` under the same key. Allowed values: `off`, `optional`, `required`. |
| `session.nvid` | string, nullable | No | Present only when the Noxtica Verification ID is enabled for your organisation. The session's NVID (`NVID-XXXX-XXXX-XXXX-XXXX`), or `null` when none was issued (an NVID is issued when the submit ends `completed` or `review_pending`). The public status of an NVID is read from `GET /v1/kyc/verification-status`, which only the hosted verification site's browser origin may call; a tenant backend takes the NVID from this field or from `data.nvid` of `kyc.session.completed`. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "session": {
    "id": "6d3c3a8e-6b4b-4a8f-8f4e-0a0e6b3d9d21",
    "status": "review_pending",
    "idType": "passport",
    "subjectId": "cust_4821",
    "captureStep": "submit",
    "createdAt": 1790762000000,
    "openedAt": 1790762100000,
    "expiresAt": 1790763900000,
    "completedAt": 1790762445000,
    "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "reasonCodes": [
      "provider_manual_review"
    ],
    "purpose": "Open your brokerage account",
    "returnUrl": "https://merchant.example.com/kyc/return",
    "selectedIdType": "passport",
    "selectedIssuingCountry": "GB",
    "consentVersion": "2026-09-01",
    "consentAcceptedAt": 1790762110000,
    "speechProfilingConsentAcceptedAt": null,
    "ageGenderConsentAcceptedAt": null,
    "evidence": {
      "front": "obj_9c1e4a",
      "selfie": "obj_5b7d02"
    },
    "decision": {
      "outcome": "review_pending",
      "reasonCodes": [
        "provider_manual_review"
      ],
      "decidedAt": 1790762445000
    },
    "stages": {
      "scene_inventory": {
        "status": "pass",
        "score": 0.97,
        "reasonCodes": []
      },
      "document_type_id": {
        "status": "pass",
        "score": 0.94,
        "reasonCodes": []
      },
      "ocr_back": {
        "status": "skipped",
        "score": null,
        "reasonCodes": [
          "not_required"
        ]
      },
      "mrz_crosscheck": {
        "status": "pass",
        "score": 0.99,
        "reasonCodes": []
      },
      "selfie_quality": {
        "status": "pass",
        "score": 0.91,
        "reasonCodes": []
      },
      "document_near_face": {
        "status": "skipped",
        "score": null,
        "reasonCodes": [
          "not_required"
        ]
      },
      "active_liveness": {
        "status": "pass",
        "score": 0.93,
        "reasonCodes": []
      },
      "speech_profile": {
        "status": "skipped",
        "score": null,
        "reasonCodes": [
          "speech_profiling_not_requested"
        ]
      },
      "speech_analysis": {
        "status": "skipped",
        "score": null,
        "reasonCodes": [
          "speech_analysis_not_requested"
        ]
      },
      "age_gender": {
        "status": "skipped",
        "score": null,
        "reasonCodes": [
          "age_gender_not_requested"
        ]
      }
    },
    "scorecard": {
      "document_front": {
        "status": "pass",
        "score": 0.94,
        "checks": [],
        "reasonCodes": []
      },
      "document_back": {
        "status": "skipped",
        "score": 0,
        "checks": [],
        "reasonCodes": [
          "not_required"
        ]
      },
      "selfie": {
        "status": "pass",
        "score": 0.91,
        "checks": [],
        "reasonCodes": []
      },
      "liveness": {
        "status": "pass",
        "score": 0.93,
        "checks": [],
        "reasonCodes": []
      },
      "voice": {
        "status": "skipped",
        "score": 0,
        "checks": [],
        "reasonCodes": [
          "not_required"
        ]
      },
      "cross_validation": {
        "status": "pass",
        "score": 0.99,
        "checks": [],
        "reasonCodes": []
      },
      "overall": {
        "status": "pass",
        "score": 0.94,
        "checks": [],
        "reasonCodes": []
      }
    },
    "nvid": "NVID-7K3M-Q9XA-T2RW-4HZD"
  },
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl "https://collect.noxtica.com/v1/kyc/sessions/example-sessionId" \
  -H "Authorization: Bearer $NOXTICA_API_KEY"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/sessions/example-sessionId', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
  },
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/sessions/example-sessionId",
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
    },
    method="GET",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Read the current hosted session (browser-facing) {#kycSessionView}

`GET /v1/kyc/session-view`

Called by the hosted verification page itself, never by a tenant backend.
**Noxtica Verification ID.** Only when the Noxtica Verification ID (NVID) is enabled for the platform, the `session` object carries `verificationIdentifier` (`KycVerificationIdentifier`) — the NVID with its derived status and an `emailOnFile` flag — or `null` when the session has no NVID. It lets the page show the ID again when a finished link is reloaded. The key is absent while the feature is not enabled; the email address is never returned.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The session snapshot. |  | `application/json` KycHostedSessionEnvelope |
| `403` | Missing/disallowed Origin, or an invalid/expired session token. | `invalid_request` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `session` | object (KycHostedSessionSnapshot) | No | The customer-facing snapshot the hosted verification page renders from. Called by the page itself, never by a tenant backend; it is not a stable integration surface. All timestamps are Unix milliseconds. Optional members appear only when the matching capability is active for the session. |
| `session.status` | string | Yes | The hosted session lifecycle status. `pending` — minted, link not opened yet. `opened` — the customer reached the capture page. `verifying` — the customer submitted and the verification is running. `review_pending` — the verification finished and awaits a Backoffice review decision (today every successful hosted capture lands here; it never expires, and `kyc.session.completed` has NOT fired yet). `completed` — a business decision exists. `unverified` — finished with a negative outcome and no manual-review code. `error` — a provider or infrastructure fault, or the submit was refused at admission. `expired` — the effective deadline passed without a decision (reported by the status read; no webhook). `cancelled_by_user` — the customer cancelled on the hosted page. Allowed values: `pending`, `opened`, `verifying`, `review_pending`, `completed`, `unverified`, `error`, `expired`, `cancelled_by_user`. |
| `session.idType` | string | Yes |  |
| `session.captureStep` | string | Yes | The furthest capture step the customer has reached. Allowed values: `consent`, `id_front`, `id_back`, `selfie`, `document_near_face`, `liveness`, `voice`, `review`, `submit`. |
| `session.expiresAt` | integer (int64) | Yes | Effective session deadline: the original mint-time deadline while the link is unopened, tightened in place to `openedAt + 1800000` (the 30-minute capture window) once opened, whichever is sooner. |
| `session.openedAt` | integer (int64), nullable | Yes | When the customer's first hosted-flow action landed (the `pending` to `opened` transition); `null` until then. |
| `session.steps` | array of string | Yes | The full step vocabulary, in order. |
| `session.configuration` | one of KycHostedSessionConfiguration or any, nullable | Yes | The tenant's hosted setup filtered by this session's options; `null` when the tenant has no hosted configuration (the page shows "Verification isn't ready yet"). |
| `session.configuration{KycHostedSessionConfiguration}` | object (KycHostedSessionConfiguration) | No |  |
| `session.configuration{KycHostedSessionConfiguration}.businessName` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.purpose` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.privacyUrl` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.supportUrl` | string, nullable | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.returnUrl` | string, nullable | Yes | The mint-time `returnUrl`. The page renders it only as a "Return to &lt;business>" link (completed outcome, or the screen shown when a step cannot continue); nothing is appended and nothing redirects automatically. |
| `session.configuration{KycHostedSessionConfiguration}.privacyVersion` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.biometricConsentVersion` | string | No | Present only when biometric consent is enabled for the platform. |
| `session.configuration{KycHostedSessionConfiguration}.documents` | array of object | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.documents[]` | object | No |  |
| `session.configuration{KycHostedSessionConfiguration}.documents[].idType` | string | Yes | Allowed values: `national_id`, `passport`, `driving_license`, `residence_permit`. |
| `session.configuration{KycHostedSessionConfiguration}.documents[].issuingCountries` | array of string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.documents[].requireBack` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.requireSelfie` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.requireVoice` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.livenessMode` | string | Yes | Allowed values: `passive`, `assisted_review`, `full_challenge`. |
| `session.configuration{KycHostedSessionConfiguration}.options` | object (KycHostedSessionOptions) | Yes | Invitation-scoped hosted-flow switches, frozen into the session at mint. When you send `hostedOptions` it must be the complete object (every required key below, no merging with defaults); omit it to use the defaults (all four document types, `allowDeviceUpload` false, `documentBack` `auto`, `documentNearFace` false, `activeLiveness` true, `speechAnalysis` false, `ageGenderEstimation` false, `emailVerification` `off`). Inside `steps`, `ageGenderEstimation` and `emailVerification` are optional. |
| `session.configuration{KycHostedSessionConfiguration}.options.allowedDocumentTypes` | array of string | Yes | At least 1 item, at most 4 items, unique items. |
| `session.configuration{KycHostedSessionConfiguration}.options.allowDeviceUpload` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps` | object | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.documentBack` | string | Yes | Allowed values: `auto`. |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.documentNearFace` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.activeLiveness` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.speechAnalysis` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.ageGenderEstimation` | boolean | No | Optional; default `false`. Adds an advisory age and gender estimate from the selfie frames (biometric, special-category data); the customer gives a separate consent on the page. Setting `true` is refused with `403 age_gender_estimation_not_enabled` (`reasonCode` `age_gender_platform_disabled` or `age_gender_tenant_acknowledgement_missing`) unless the platform has enabled the feature AND your organisation has recorded the matching legal-basis acknowledgement in the Backoffice. It never fails a verification. |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.emailVerification` | string | No | Optional (added 2026-10-01; default `off` when the key is absent). When omitted from a mint request your organisation's default applies; an explicit value wins in both directions. An explicit `optional` or `required` is refused with `400 email_verification_unavailable` until the platform has enabled hosted email verification. The mint response echoes the effective mode. Allowed values: `off`, `optional`, `required`. |
| `session.selection` | one of object or any, nullable | Yes | The customer's document choice, once made. |
| `session.selection{variant 1}` | object | No |  |
| `session.selection{variant 1}.idType` | string | Yes |  |
| `session.selection{variant 1}.issuingCountry` | string | Yes |  |
| `session.consent` | one of KycHostedSessionConsent or any, nullable | Yes | The privacy-notice receipt; immutable once set. |
| `session.consent{KycHostedSessionConsent}` | object (KycHostedSessionConsent) | No |  |
| `session.consent{KycHostedSessionConsent}.version` | string | Yes |  |
| `session.consent{KycHostedSessionConsent}.acceptedAt` | integer (int64) | Yes |  |
| `session.biometricConsent` | one of KycHostedSessionConsent or any, nullable | No | The biometric-processing consent receipt. Present only when biometric consent is enabled for the platform. |
| `session.biometricConsent{KycHostedSessionConsent}` | object (KycHostedSessionConsent) | No |  |
| `session.biometricConsent{KycHostedSessionConsent}.version` | string | Yes |  |
| `session.biometricConsent{KycHostedSessionConsent}.acceptedAt` | integer (int64) | Yes |  |
| `session.speechProfilingConsent` | object | No |  |
| `session.speechProfilingConsent.acceptedAt` | integer (int64) | Yes |  |
| `session.ageGenderConsent` | object | No |  |
| `session.ageGenderConsent.acceptedAt` | integer (int64) | Yes |  |
| `session.evidence` | object (KycSessionEvidenceRefs) | Yes | Opaque object ids of the finalized (usable) uploads, by role. Never a URL or media bytes. A role appears only once its upload is finalized. |
| `session.evidence.front` | string | No |  |
| `session.evidence.back` | string | No |  |
| `session.evidence.selfie` | string | No |  |
| `session.evidence.voice` | string | No |  |
| `session.evidence.documentNearFace` | string | No |  |
| `session.resume` | object (KycHostedSessionResume) | Yes | Whether the customer link this snapshot answers for is still usable for further capture. |
| `session.resume.eligible` | boolean | Yes |  |
| `session.resume.reason` | string | No | Present when `eligible` is `false`. `cancelled` means the session is in the terminal `cancelled_by_user` status (defensive — every customer route already answers `410 session_cancelled` for such a session, so a snapshot of a cancelled row is not normally served to the hosted page). Allowed values: `expired`, `completed`, `consumed`, `cancelled`. |
| `session.decision` | object (KycSessionDecision) | No | The decision block, present once the pipeline or a review decision has produced one. `outcome` maps from the session status: `completed` to `approved`, `unverified` to `rejected`, `review_pending` to `review_pending`, `error` to `error`. |
| `session.decision.outcome` | string | Yes | Allowed values: `approved`, `rejected`, `review_pending`, `error`. |
| `session.decision.reasonCodes` | array of string | Yes |  |
| `session.decision.decidedAt` | integer (int64) | Yes | Unix ms. |
| `session.processing` | object | No | Coarse "what is happening now" for the status page; absent while the customer is still capturing. |
| `session.processing.stage` | string | Yes | Allowed values: `uploading`, `queued`, `evaluating`, `review_pending`, `decided`. |
| `session.processing.updatedAt` | integer (int64) | Yes |  |
| `session.evidenceQuality` | object | No | Per-role structural quality receipt; absent until a role's evidence has been inspected. |
| `session.livenessSteps` | array of object | No | The configured liveness challenge with per-step completion. |
| `session.livenessSteps[]` | object | No |  |
| `session.livenessSteps[].id` | string | Yes |  |
| `session.livenessSteps[].kind` | string | Yes |  |
| `session.livenessSteps[].completed` | boolean | Yes |  |
| `session.voiceChallenge` | object | No | The anti-replay digit phrase; present only when the tenant requires the voice step. |
| `session.voiceChallenge.phrase` | string | Yes |  |
| `session.failureBudget` | object | Yes | Attempt counters per retryable step (`front`, `back`, `selfie`, `liveness`, `voice`, `upload`). |
| `session.failureBudget.*` | object | No |  |
| `session.failureBudget.*.attempts` | integer | Yes |  |
| `session.failureBudget.*.max` | integer | Yes |  |
| `session.deviceChoice` | string | No | A previously recorded cross-device continuation choice; absent when none was recorded. Allowed values: `desktop`, `phone`. |
| `session.serverNow` | integer (int64) | Yes | The server clock when the snapshot was built; the page computes remaining time against it, not against its own clock. |
| `session.sessionExpiresAt` | integer (int64) | Yes | The same deadline as `expiresAt`, named for the two-timer UI. |
| `session.currentStep` | string, nullable | No | The step the customer most recently entered. Present only when per-step timers are enabled for the platform. |
| `session.stepStartedAt` | integer (int64), nullable | No |  |
| `session.stepExpiresAt` | integer (int64), nullable | No | `min(stepStartedAt + 10 minutes, sessionExpiresAt)`; `null` for an untimed step. |
| `session.capabilities` | object (KycHostedSessionCapabilities) | No | Server capabilities the hosted verification page may rely on. The object lists only the capabilities that are active for this session and is omitted entirely when none is. `cancel` is advertised when the collector serves `POST /v1/kyc/session-cancel` AND has cancel armed; `emailVerification` is advertised when hosted email verification is enabled for the platform and the session's effective mode is not `off`. The hosted page treats "field present" as the only capability signal (it never probes a route, since a probe could itself create state) and, when a capability is absent, falls back to the behaviour of an older collector (for cancel: a client-only end screen with no network call; for email verification: no email step). |
| `session.capabilities.cancel` | boolean | No | The collector accepts `POST /v1/kyc/session-cancel` for this session. Allowed values: `true`. |
| `session.capabilities.emailVerification` | boolean | No | The hosted flow includes the email-verification step for this session. Allowed values: `true`. |
| `session.emailVerification` | object (KycEmailVerificationState) | No | The customer-facing email-verification state returned by the hosted page's email routes and embedded in the snapshot as `emailVerification`. The address is always masked (`j***@example.com`). |
| `session.emailVerification.mode` | string | Yes | Allowed values: `optional`, `required`. |
| `session.emailVerification.email` | string, nullable | Yes | The masked address; `null` before one was entered. |
| `session.emailVerification.verified` | boolean | Yes |  |
| `session.emailVerification.verifiedAt` | integer (int64), nullable | Yes |  |
| `session.emailVerification.expiresAt` | integer (int64), nullable | Yes | Deadline of the live confirmation link (30 minutes or the session end, whichever is sooner); `null` when none is live. |
| `session.emailVerification.resendAvailableAt` | integer (int64), nullable | Yes | Earliest next send while the 60 second cooldown runs; `null` when a resend is allowed now or the cap is exhausted. |
| `session.emailVerification.sendsRemaining` | integer | Yes | Sends left of the 5 allowed per session. |
| `session.verificationIdentifier` | one of KycVerificationIdentifier or any, nullable | No | Present only while the NVID is enabled for the platform; `null` when the session has none. |
| `session.verificationIdentifier{KycVerificationIdentifier}` | object (KycVerificationIdentifier) | No | The session's Noxtica Verification ID as shown on the hosted page's final screen (`session.verificationIdentifier` on `GET /v1/kyc/session-view`). Customer-facing; never carries an address. |
| `session.verificationIdentifier{KycVerificationIdentifier}.nvid` | string | Yes | Canonical NVID, NVID-XXXX-XXXX-XXXX-XXXX. |
| `session.verificationIdentifier{KycVerificationIdentifier}.status` | string | Yes | Public status of a Noxtica Verification ID. `revoked` wins over everything; `pending` = the verification awaits a review decision; `expired` = 365 days after completion; otherwise `verified`. Allowed values: `verified`, `pending`, `revoked`, `expired`. |
| `session.verificationIdentifier{KycVerificationIdentifier}.verifiedAt` | integer (int64), nullable | Yes | Unix ms of completion; `null` while pending. |
| `session.verificationIdentifier{KycVerificationIdentifier}.expiresAt` | integer (int64), nullable | Yes | Unix ms, completion + 365 days; `null` while pending. |
| `session.verificationIdentifier{KycVerificationIdentifier}.emailOnFile` | boolean | Yes | Whether the person gave an address to receive the NVID. The address itself is never returned. |
| `session.evidenceUpload` | object | No | Capability marker for step-wise evidence upload; present only when that capability is enabled for the platform. |
| `session.evidenceReceipts` | object | No | Server-authoritative per-step evidence receipts; present with `evidenceUpload`. |
| `session.scorecard` | object | No | The customer-masked per-step scorecard (`GET /v1/kyc/session-view` only): a status per step and nothing else; no score, check or reason code reaches a browser. The full numeric version is `session.scorecard` on the tenant status read. |
| `session.scorecard.document_front` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.document_front.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.document_back` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.document_back.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.selfie` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.selfie.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.liveness` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.liveness.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.voice` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.voice.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.cross_validation` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.cross_validation.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.overall` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.overall.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "session": {
    "status": "opened",
    "idType": "passport",
    "captureStep": "consent",
    "expiresAt": 1790763900000,
    "openedAt": 1790762100000,
    "steps": [
      "consent",
      "id_front",
      "id_back",
      "selfie",
      "document_near_face",
      "liveness",
      "voice",
      "review",
      "submit"
    ],
    "configuration": {
      "businessName": "Example Bank",
      "purpose": "Open your brokerage account",
      "privacyUrl": "https://merchant.example.com/privacy",
      "supportUrl": "https://merchant.example.com/support",
      "returnUrl": "https://merchant.example.com/kyc/return",
      "privacyVersion": "2026-09-01",
      "documents": [
        {
          "idType": "passport",
          "issuingCountries": [
            "GB"
          ],
          "requireBack": false
        }
      ],
      "requireSelfie": true,
      "requireVoice": false,
      "livenessMode": "passive",
      "options": {
        "allowedDocumentTypes": [
          "passport"
        ],
        "allowDeviceUpload": false,
        "steps": {
          "documentBack": "auto",
          "documentNearFace": false,
          "activeLiveness": true,
          "speechAnalysis": false
        }
      }
    },
    "selection": null,
    "consent": null,
    "evidence": {},
    "resume": {
      "eligible": true
    },
    "failureBudget": {
      "front": {
        "attempts": 0,
        "max": 10
      },
      "back": {
        "attempts": 0,
        "max": 10
      },
      "selfie": {
        "attempts": 0,
        "max": 10
      },
      "liveness": {
        "attempts": 0,
        "max": 3
      },
      "voice": {
        "attempts": 0,
        "max": 10
      },
      "upload": {
        "attempts": 0,
        "max": 3
      }
    },
    "serverNow": 1790762150000,
    "sessionExpiresAt": 1790763900000
  },
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl "https://collect.noxtica.com/v1/kyc/session-view" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-view', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
  },
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-view",
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
    },
    method="GET",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Checkpoint hosted-flow progress (browser-facing) {#kycSessionProgress}

`POST /v1/kyc/session-progress`

Records consent, document/issuing-country selection, and step completion as the customer moves through the hosted flow.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Request body

Content type `application/json`, required.

Type: object.

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Checkpoint recorded; returns the updated snapshot. |  | `application/json` KycHostedSessionEnvelope |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `403` | Missing/disallowed Origin, or an invalid/expired session token. | `invalid_request` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | The session is already terminal. | `invalid_request` | `application/json` ErrorEnvelope |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No |  |
| `session` | object (KycHostedSessionSnapshot) | No | The customer-facing snapshot the hosted verification page renders from. Called by the page itself, never by a tenant backend; it is not a stable integration surface. All timestamps are Unix milliseconds. Optional members appear only when the matching capability is active for the session. |
| `session.status` | string | Yes | The hosted session lifecycle status. `pending` — minted, link not opened yet. `opened` — the customer reached the capture page. `verifying` — the customer submitted and the verification is running. `review_pending` — the verification finished and awaits a Backoffice review decision (today every successful hosted capture lands here; it never expires, and `kyc.session.completed` has NOT fired yet). `completed` — a business decision exists. `unverified` — finished with a negative outcome and no manual-review code. `error` — a provider or infrastructure fault, or the submit was refused at admission. `expired` — the effective deadline passed without a decision (reported by the status read; no webhook). `cancelled_by_user` — the customer cancelled on the hosted page. Allowed values: `pending`, `opened`, `verifying`, `review_pending`, `completed`, `unverified`, `error`, `expired`, `cancelled_by_user`. |
| `session.idType` | string | Yes |  |
| `session.captureStep` | string | Yes | The furthest capture step the customer has reached. Allowed values: `consent`, `id_front`, `id_back`, `selfie`, `document_near_face`, `liveness`, `voice`, `review`, `submit`. |
| `session.expiresAt` | integer (int64) | Yes | Effective session deadline: the original mint-time deadline while the link is unopened, tightened in place to `openedAt + 1800000` (the 30-minute capture window) once opened, whichever is sooner. |
| `session.openedAt` | integer (int64), nullable | Yes | When the customer's first hosted-flow action landed (the `pending` to `opened` transition); `null` until then. |
| `session.steps` | array of string | Yes | The full step vocabulary, in order. |
| `session.configuration` | one of KycHostedSessionConfiguration or any, nullable | Yes | The tenant's hosted setup filtered by this session's options; `null` when the tenant has no hosted configuration (the page shows "Verification isn't ready yet"). |
| `session.configuration{KycHostedSessionConfiguration}` | object (KycHostedSessionConfiguration) | No |  |
| `session.configuration{KycHostedSessionConfiguration}.businessName` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.purpose` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.privacyUrl` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.supportUrl` | string, nullable | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.returnUrl` | string, nullable | Yes | The mint-time `returnUrl`. The page renders it only as a "Return to &lt;business>" link (completed outcome, or the screen shown when a step cannot continue); nothing is appended and nothing redirects automatically. |
| `session.configuration{KycHostedSessionConfiguration}.privacyVersion` | string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.biometricConsentVersion` | string | No | Present only when biometric consent is enabled for the platform. |
| `session.configuration{KycHostedSessionConfiguration}.documents` | array of object | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.documents[]` | object | No |  |
| `session.configuration{KycHostedSessionConfiguration}.documents[].idType` | string | Yes | Allowed values: `national_id`, `passport`, `driving_license`, `residence_permit`. |
| `session.configuration{KycHostedSessionConfiguration}.documents[].issuingCountries` | array of string | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.documents[].requireBack` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.requireSelfie` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.requireVoice` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.livenessMode` | string | Yes | Allowed values: `passive`, `assisted_review`, `full_challenge`. |
| `session.configuration{KycHostedSessionConfiguration}.options` | object (KycHostedSessionOptions) | Yes | Invitation-scoped hosted-flow switches, frozen into the session at mint. When you send `hostedOptions` it must be the complete object (every required key below, no merging with defaults); omit it to use the defaults (all four document types, `allowDeviceUpload` false, `documentBack` `auto`, `documentNearFace` false, `activeLiveness` true, `speechAnalysis` false, `ageGenderEstimation` false, `emailVerification` `off`). Inside `steps`, `ageGenderEstimation` and `emailVerification` are optional. |
| `session.configuration{KycHostedSessionConfiguration}.options.allowedDocumentTypes` | array of string | Yes | At least 1 item, at most 4 items, unique items. |
| `session.configuration{KycHostedSessionConfiguration}.options.allowDeviceUpload` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps` | object | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.documentBack` | string | Yes | Allowed values: `auto`. |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.documentNearFace` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.activeLiveness` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.speechAnalysis` | boolean | Yes |  |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.ageGenderEstimation` | boolean | No | Optional; default `false`. Adds an advisory age and gender estimate from the selfie frames (biometric, special-category data); the customer gives a separate consent on the page. Setting `true` is refused with `403 age_gender_estimation_not_enabled` (`reasonCode` `age_gender_platform_disabled` or `age_gender_tenant_acknowledgement_missing`) unless the platform has enabled the feature AND your organisation has recorded the matching legal-basis acknowledgement in the Backoffice. It never fails a verification. |
| `session.configuration{KycHostedSessionConfiguration}.options.steps.emailVerification` | string | No | Optional (added 2026-10-01; default `off` when the key is absent). When omitted from a mint request your organisation's default applies; an explicit value wins in both directions. An explicit `optional` or `required` is refused with `400 email_verification_unavailable` until the platform has enabled hosted email verification. The mint response echoes the effective mode. Allowed values: `off`, `optional`, `required`. |
| `session.selection` | one of object or any, nullable | Yes | The customer's document choice, once made. |
| `session.selection{variant 1}` | object | No |  |
| `session.selection{variant 1}.idType` | string | Yes |  |
| `session.selection{variant 1}.issuingCountry` | string | Yes |  |
| `session.consent` | one of KycHostedSessionConsent or any, nullable | Yes | The privacy-notice receipt; immutable once set. |
| `session.consent{KycHostedSessionConsent}` | object (KycHostedSessionConsent) | No |  |
| `session.consent{KycHostedSessionConsent}.version` | string | Yes |  |
| `session.consent{KycHostedSessionConsent}.acceptedAt` | integer (int64) | Yes |  |
| `session.biometricConsent` | one of KycHostedSessionConsent or any, nullable | No | The biometric-processing consent receipt. Present only when biometric consent is enabled for the platform. |
| `session.biometricConsent{KycHostedSessionConsent}` | object (KycHostedSessionConsent) | No |  |
| `session.biometricConsent{KycHostedSessionConsent}.version` | string | Yes |  |
| `session.biometricConsent{KycHostedSessionConsent}.acceptedAt` | integer (int64) | Yes |  |
| `session.speechProfilingConsent` | object | No |  |
| `session.speechProfilingConsent.acceptedAt` | integer (int64) | Yes |  |
| `session.ageGenderConsent` | object | No |  |
| `session.ageGenderConsent.acceptedAt` | integer (int64) | Yes |  |
| `session.evidence` | object (KycSessionEvidenceRefs) | Yes | Opaque object ids of the finalized (usable) uploads, by role. Never a URL or media bytes. A role appears only once its upload is finalized. |
| `session.evidence.front` | string | No |  |
| `session.evidence.back` | string | No |  |
| `session.evidence.selfie` | string | No |  |
| `session.evidence.voice` | string | No |  |
| `session.evidence.documentNearFace` | string | No |  |
| `session.resume` | object (KycHostedSessionResume) | Yes | Whether the customer link this snapshot answers for is still usable for further capture. |
| `session.resume.eligible` | boolean | Yes |  |
| `session.resume.reason` | string | No | Present when `eligible` is `false`. `cancelled` means the session is in the terminal `cancelled_by_user` status (defensive — every customer route already answers `410 session_cancelled` for such a session, so a snapshot of a cancelled row is not normally served to the hosted page). Allowed values: `expired`, `completed`, `consumed`, `cancelled`. |
| `session.decision` | object (KycSessionDecision) | No | The decision block, present once the pipeline or a review decision has produced one. `outcome` maps from the session status: `completed` to `approved`, `unverified` to `rejected`, `review_pending` to `review_pending`, `error` to `error`. |
| `session.decision.outcome` | string | Yes | Allowed values: `approved`, `rejected`, `review_pending`, `error`. |
| `session.decision.reasonCodes` | array of string | Yes |  |
| `session.decision.decidedAt` | integer (int64) | Yes | Unix ms. |
| `session.processing` | object | No | Coarse "what is happening now" for the status page; absent while the customer is still capturing. |
| `session.processing.stage` | string | Yes | Allowed values: `uploading`, `queued`, `evaluating`, `review_pending`, `decided`. |
| `session.processing.updatedAt` | integer (int64) | Yes |  |
| `session.evidenceQuality` | object | No | Per-role structural quality receipt; absent until a role's evidence has been inspected. |
| `session.livenessSteps` | array of object | No | The configured liveness challenge with per-step completion. |
| `session.livenessSteps[]` | object | No |  |
| `session.livenessSteps[].id` | string | Yes |  |
| `session.livenessSteps[].kind` | string | Yes |  |
| `session.livenessSteps[].completed` | boolean | Yes |  |
| `session.voiceChallenge` | object | No | The anti-replay digit phrase; present only when the tenant requires the voice step. |
| `session.voiceChallenge.phrase` | string | Yes |  |
| `session.failureBudget` | object | Yes | Attempt counters per retryable step (`front`, `back`, `selfie`, `liveness`, `voice`, `upload`). |
| `session.failureBudget.*` | object | No |  |
| `session.failureBudget.*.attempts` | integer | Yes |  |
| `session.failureBudget.*.max` | integer | Yes |  |
| `session.deviceChoice` | string | No | A previously recorded cross-device continuation choice; absent when none was recorded. Allowed values: `desktop`, `phone`. |
| `session.serverNow` | integer (int64) | Yes | The server clock when the snapshot was built; the page computes remaining time against it, not against its own clock. |
| `session.sessionExpiresAt` | integer (int64) | Yes | The same deadline as `expiresAt`, named for the two-timer UI. |
| `session.currentStep` | string, nullable | No | The step the customer most recently entered. Present only when per-step timers are enabled for the platform. |
| `session.stepStartedAt` | integer (int64), nullable | No |  |
| `session.stepExpiresAt` | integer (int64), nullable | No | `min(stepStartedAt + 10 minutes, sessionExpiresAt)`; `null` for an untimed step. |
| `session.capabilities` | object (KycHostedSessionCapabilities) | No | Server capabilities the hosted verification page may rely on. The object lists only the capabilities that are active for this session and is omitted entirely when none is. `cancel` is advertised when the collector serves `POST /v1/kyc/session-cancel` AND has cancel armed; `emailVerification` is advertised when hosted email verification is enabled for the platform and the session's effective mode is not `off`. The hosted page treats "field present" as the only capability signal (it never probes a route, since a probe could itself create state) and, when a capability is absent, falls back to the behaviour of an older collector (for cancel: a client-only end screen with no network call; for email verification: no email step). |
| `session.capabilities.cancel` | boolean | No | The collector accepts `POST /v1/kyc/session-cancel` for this session. Allowed values: `true`. |
| `session.capabilities.emailVerification` | boolean | No | The hosted flow includes the email-verification step for this session. Allowed values: `true`. |
| `session.emailVerification` | object (KycEmailVerificationState) | No | The customer-facing email-verification state returned by the hosted page's email routes and embedded in the snapshot as `emailVerification`. The address is always masked (`j***@example.com`). |
| `session.emailVerification.mode` | string | Yes | Allowed values: `optional`, `required`. |
| `session.emailVerification.email` | string, nullable | Yes | The masked address; `null` before one was entered. |
| `session.emailVerification.verified` | boolean | Yes |  |
| `session.emailVerification.verifiedAt` | integer (int64), nullable | Yes |  |
| `session.emailVerification.expiresAt` | integer (int64), nullable | Yes | Deadline of the live confirmation link (30 minutes or the session end, whichever is sooner); `null` when none is live. |
| `session.emailVerification.resendAvailableAt` | integer (int64), nullable | Yes | Earliest next send while the 60 second cooldown runs; `null` when a resend is allowed now or the cap is exhausted. |
| `session.emailVerification.sendsRemaining` | integer | Yes | Sends left of the 5 allowed per session. |
| `session.verificationIdentifier` | one of KycVerificationIdentifier or any, nullable | No | Present only while the NVID is enabled for the platform; `null` when the session has none. |
| `session.verificationIdentifier{KycVerificationIdentifier}` | object (KycVerificationIdentifier) | No | The session's Noxtica Verification ID as shown on the hosted page's final screen (`session.verificationIdentifier` on `GET /v1/kyc/session-view`). Customer-facing; never carries an address. |
| `session.verificationIdentifier{KycVerificationIdentifier}.nvid` | string | Yes | Canonical NVID, NVID-XXXX-XXXX-XXXX-XXXX. |
| `session.verificationIdentifier{KycVerificationIdentifier}.status` | string | Yes | Public status of a Noxtica Verification ID. `revoked` wins over everything; `pending` = the verification awaits a review decision; `expired` = 365 days after completion; otherwise `verified`. Allowed values: `verified`, `pending`, `revoked`, `expired`. |
| `session.verificationIdentifier{KycVerificationIdentifier}.verifiedAt` | integer (int64), nullable | Yes | Unix ms of completion; `null` while pending. |
| `session.verificationIdentifier{KycVerificationIdentifier}.expiresAt` | integer (int64), nullable | Yes | Unix ms, completion + 365 days; `null` while pending. |
| `session.verificationIdentifier{KycVerificationIdentifier}.emailOnFile` | boolean | Yes | Whether the person gave an address to receive the NVID. The address itself is never returned. |
| `session.evidenceUpload` | object | No | Capability marker for step-wise evidence upload; present only when that capability is enabled for the platform. |
| `session.evidenceReceipts` | object | No | Server-authoritative per-step evidence receipts; present with `evidenceUpload`. |
| `session.scorecard` | object | No | The customer-masked per-step scorecard (`GET /v1/kyc/session-view` only): a status per step and nothing else; no score, check or reason code reaches a browser. The full numeric version is `session.scorecard` on the tenant status read. |
| `session.scorecard.document_front` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.document_front.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.document_back` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.document_back.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.selfie` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.selfie.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.liveness` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.liveness.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.voice` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.voice.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.cross_validation` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.cross_validation.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `session.scorecard.overall` | object (KycCustomerScorecardEntry) | No |  |
| `session.scorecard.overall.status` | string | Yes | Allowed values: `pass`, `needs_review`, `failed`, `skipped`, `pending`. |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "session": {
    "status": "opened",
    "idType": "passport",
    "captureStep": "id_front",
    "expiresAt": 1790763900000,
    "openedAt": 1790762100000,
    "steps": [
      "consent",
      "id_front",
      "id_back",
      "selfie",
      "document_near_face",
      "liveness",
      "voice",
      "review",
      "submit"
    ],
    "configuration": {
      "businessName": "Example Bank",
      "purpose": "Open your brokerage account",
      "privacyUrl": "https://merchant.example.com/privacy",
      "supportUrl": "https://merchant.example.com/support",
      "returnUrl": "https://merchant.example.com/kyc/return",
      "privacyVersion": "2026-09-01",
      "documents": [
        {
          "idType": "passport",
          "issuingCountries": [
            "GB"
          ],
          "requireBack": false
        }
      ],
      "requireSelfie": true,
      "requireVoice": false,
      "livenessMode": "passive",
      "options": {
        "allowedDocumentTypes": [
          "passport"
        ],
        "allowDeviceUpload": false,
        "steps": {
          "documentBack": "auto",
          "documentNearFace": false,
          "activeLiveness": true,
          "speechAnalysis": false
        }
      }
    },
    "selection": {
      "idType": "passport",
      "issuingCountry": "GB"
    },
    "consent": {
      "version": "2026-09-01",
      "acceptedAt": 1790762110000
    },
    "evidence": {},
    "resume": {
      "eligible": true
    },
    "failureBudget": {
      "front": {
        "attempts": 0,
        "max": 10
      },
      "back": {
        "attempts": 0,
        "max": 10
      },
      "selfie": {
        "attempts": 0,
        "max": 10
      },
      "liveness": {
        "attempts": 0,
        "max": 3
      },
      "voice": {
        "attempts": 0,
        "max": 10
      },
      "upload": {
        "attempts": 0,
        "max": 3
      }
    },
    "serverNow": 1790762160000,
    "sessionExpiresAt": 1790763900000
  },
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/session-progress" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-progress', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({}),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-progress",
    data=json.dumps({}).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Cancel the hosted session (browser-facing) {#kycSessionCancel}

`POST /v1/kyc/session-cancel`

Called by the hosted verification page when the end customer confirms they do not want to continue. Moves the session to the terminal status `cancelled_by_user`, recorded server-side (`cancelledAt`, optional closed-enum `cancelReason`); the cancel itself hard-deletes no evidence (evidence retention rules are unchanged).
**Capability.** A collector that serves and has armed this route advertises it on every session snapshot (`GET /v1/kyc/session-view`, `POST /v1/kyc/session-progress`) as `session.capabilities.cancel: true`. When the field is absent (an older collector, or cancel disarmed) the hosted page never calls this route and shows a client-only end screen instead. An older or unarmed collector answers this route with the same byte-identical V1 `404` as any other gated route.
**When it is allowed.** Cancel is accepted from any capture, consent, selection, or review step (session `pending` or `opened`). It is refused with `409 session_not_cancellable` once the session is `verifying` (the single-use submit gate is consumed and the verification is being paid for) or already `completed`, `unverified`, `error`, or `review_pending`. A session past its deadline answers `410 session_expired`.
**Idempotency.** Repeating the call on an already-cancelled session returns `200` with the ORIGINAL `cancelledAt` and `cancelReason`.
**After a cancel** every other customer route (`session-view`, `session-progress`, `session-submit`, `session-evidence/handle`, `session-evidence/finalize`) answers `410 session_cancelled` (`code: kyc_session_cancelled`).
**Webhook.** The call that actually transitions the session emits ONE `kyc.session.cancelled` webhook to every enabled endpoint subscribed to that event type (subscription is opt-in per endpoint; nothing is sent to an endpoint that did not list it). The envelope `data` is `{schemaVersion: "1", sessionId, status: "cancelled_by_user", cancelReason (closed enum or `null`), cancelledAt (Unix ms), occurredAt (= cancelledAt), deliveryId}` — plus `email` and `emailVerified` when hosted email verification is enabled for the platform AND the session has a stored address (`KycSessionCancelledWebhookData`) — and carries no other subject data. An idempotent replay, a `409` and an expiry emit nothing. Delivery is at-least-once through the same signed outbox as `kyc.session.completed`; receivers MUST dedup by `deliveryId`. A webhook is a notification, not authority: `GET /v1/kyc/sessions/{sessionId}` confirms the status `cancelled_by_user` but does NOT return `cancelledAt` or `cancelReason`; those are delivered only in this webhook.
**Billing.** A cancelled session is never billed as a verification: a cancel creates no verification request and no spend reservation, and never records the `kyc.verification` meter (that meter is recorded only at submit-time spend admission). Only the request-level `endpoint.collect.request` count applies, as for every hosted session call.
Rate limits: a per-IP limit, then a per-session limit — the same as `session-progress`.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Request body

Content type `application/json`, optional.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | No | The closed set of reasons an end customer may give when cancelling a hosted session. There is deliberately no free-text reason. Allowed values: `not_now`, `privacy_concern`, `no_document`, `technical_problem`, `other`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The session is `cancelled_by_user` — either this call cancelled it or it was already cancelled (idempotent replay; the original `cancelledAt` and `cancelReason` are returned). |  | `application/json` KycSessionCancelResponse |
| `400` | `invalid_request` — invalid body, or `reason` outside the closed enum. | `invalid_request` | `application/json` ErrorEnvelope |
| `401` | `invalid_session` — the session token is invalid. | `invalid_session` | `application/json` ErrorEnvelope |
| `403` | `origin_not_allowed` — missing or disallowed `Origin` (same origin enforcement as every other hosted route). | `origin_not_allowed` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | `session_not_cancellable` — the session is already `verifying`, `completed`, `unverified`, `error`, or `review_pending`; `status` names which. | `session_not_cancellable` | `application/json` KycSessionNotCancellableError |
| `410` | The session has expired — past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. The hosted verification page MUST stop polling on this response. | `kyc_session_expired`, `session_cancelled`, `session_expired` | `application/json` KycSessionExpiredError |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `invalid_request` — an unexpected internal fault while handling the cancel (any uncaught error in the handler). The body is the same `{error: invalid_request, requestId}` envelope as a `400`, so clients MUST key off the status code, not the error string, to tell the two apart. The session is not reported as cancelled; it is safe to retry the call (cancel is idempotent). | `invalid_request` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `status` | string | Yes | Allowed values: `cancelled_by_user`. |
| `cancelledAt` | integer (int64) | Yes | Unix ms of the cancel. An idempotent replay returns the ORIGINAL value, never a fresh timestamp. |
| `cancelReason` | one of KycSessionCancelReason or any, nullable | Yes | The reason recorded by the FIRST successful call (`null` if none was given). A replay returns the original reason. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "status": "cancelled_by_user",
  "cancelledAt": 1768434600000,
  "cancelReason": "not_now",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/session-cancel" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "reason": "not_now"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-cancel', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    reason: 'not_now',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-cancel",
    data=json.dumps({
        "reason": "not_now",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Submit the hosted session for verification (browser-facing, one-shot) {#kycSessionSubmit}

`POST /v1/kyc/session-submit`

Drives the SAME `handleVerifyAction` pipeline `POST /v1/kyc/verify` uses, under the session's own tenant, with a server-derived payload (evidence object ids resolved exclusively from this session's own finalized evidence — a browser can never substitute its own payload fields). Single-use: a lost single-use claim on a concurrent resubmit returns `verify_gate_consumed`, distinct from a stale replay (`session_not_submittable`). See the closed `SUBMIT REFUSAL CODES` table below — hardened 2026-09-13 (D8b) so every pre-verification failure returns a distinct, actionable code instead of a shared `invalid_request`.
**Email verification.** When the session's effective email-verification mode is `required` and the customer has not verified an address, the submit is refused with `409 email_verification_required`; the session stays open and can be submitted once the address is verified. Only possible while hosted email verification is enabled for the platform.
**Noxtica Verification ID.** When the Noxtica Verification ID (NVID) is enabled for the platform, a `200` response also carries `nvid` — the session's NVID when the outcome is positive (`completed` or `review_pending`), otherwise `null`. The key is absent while the feature is not enabled.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Request body

Content type `application/json`, optional.

Type: object.

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | A resolved verify attempt (verified, unverified, or review_pending). |  | `application/json` KycVerifyResponse |
| `400` | `idempotency_key_invalid` or `payload_invalid` — both new distinct codes as of 2026-09-13. | `idempotency_key_invalid`, `invalid_request`, `payload_invalid` | `application/json` ErrorEnvelope |
| `403` | Missing/disallowed Origin, or an invalid/expired session token. | `invalid_request` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | `session_not_submittable` (already terminal/mid-flight), `verify_gate_consumed` (NEW 2026-09-13 — lost the single-use race), or `email_verification_required` (NEW 2026-10-01 — the session's effective email-verification mode is `required` and no address has been verified yet). | `email_verification_required`, `invalid_request`, `session_not_submittable`, `verify_gate_consumed` | `application/json` ErrorEnvelope |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `429` | `session_rate_limited` (the per-session budget, NEW 2026-09-13, distinct from the dispatcher-level 429 below) or the per-IP hosted-page limit. | `invalid_request`, `session_rate_limited` | `application/json` ErrorEnvelope |
| `503` | `nonce_mint_unavailable` (NEW), `submit_internal_error` (NEW), `hosted_verify_not_configured`, or `kyc_legal_basis_receipt_unavailable`. | `hosted_verify_not_configured`, `invalid_request`, `kyc_legal_basis_receipt_unavailable`, `nonce_mint_unavailable`, `submit_internal_error` | `application/json` ErrorEnvelope |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `verdict` | string | Yes | `error` is an infra/provider fault, never a statement about the subject — always HTTP 200 (FR-6). Allowed values: `verified`, `unverified`, `error`. |
| `verificationLevel` | string | Yes | Allowed values: `verified`, `partial`, `unverified`, `inconclusive`. |
| `reasonCodes` | array of string | Yes |  |
| `verificationId` | string (uuid) | Yes |  |
| `requestId` | string | Yes |  |
| `legalBasisRef` | string | No | Present only when this request's tenant is running under classified legal-basis receipt gating. |
| `nfcAssurance` | object | No | Present only for eMRTD/passport-chip verification paths. |
| `deviceTrust` | object | No | Present only when the request set deviceContext:true and device-intel evidence is available. ADVISORY ONLY — never fed back into verdict/reasonCodes. |

**Response example** (`200`)

```json
{
  "ok": true,
  "verdict": "verified",
  "verificationLevel": "verified",
  "reasonCodes": [
    "provider_timeout"
  ],
  "verificationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "requestId": "req-test",
  "legalBasisRef": "example-legalBasisRef",
  "nfcAssurance": {},
  "deviceTrust": {}
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/session-submit" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-submit', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({}),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-submit",
    data=json.dumps({}).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Email the person their Noxtica Verification ID (browser-facing, optional, consented) {#kycSessionVerificationIdEmail}

`POST /v1/kyc/session-verification-id/email`

Called by the hosted verification page when the person, on the final screen, asks Noxtica to email them their Noxtica Verification ID (NVID) and its public status link. Optional and independent of the verification outcome. **The person's explicit consent is required** (`consent.version` `nvid-email-v1`, `consent.accepted` `true`; the hosted checkbox is unticked by default). Noxtica — not the tenant — is the controller for this address: it is sealed (AES-256-GCM) at rest, used only to send this email, kept only with the verification record, never returned by any API and never disclosed to the tenant.
Only available while the NVID feature is enabled for the platform; otherwise the byte-identical gated `404`. Requires an issued, non-revoked NVID on the session (`409 verification_id_unavailable` otherwise). Limits: per-session, per-IP and per-address limiters, at most 3 sends per session from the hosted page, at least 60 seconds between sends (`429 rate_limited`).

**Authentication:** Bearer token (`SessionTokenAuth`).

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | Yes | The address to send the NVID to (normalised server-side). At most 254 characters. |
| `locale` | string | Yes | One of the 36 supported UI locales; the email is rendered in it (unsupported falls back to `en`). |
| `consent` | object | Yes |  |
| `consent.version` | string | Yes | Allowed values: `nvid-email-v1`. |
| `consent.accepted` | boolean | Yes | Allowed values: `true`. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The email was accepted for delivery. |  | `application/json` |
| `400` | `invalid_email` or `consent_required`. | `consent_required`, `invalid_email` | `application/json` ErrorEnvelope |
| `403` | Missing/disallowed Origin, or an invalid/expired session token. | `origin_not_allowed` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | `verification_id_unavailable` — the session has no NVID, or it was revoked. | `verification_id_unavailable` | `application/json` ErrorEnvelope |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded`, `rate_limited` | `application/json` |
| `502` | `email_send_failed` — the provider refused or failed; the send slot is released. | `email_send_failed` | `application/json` ErrorEnvelope |
| `503` | `email_unavailable` — the address cannot be sealed right now; nothing was stored. | `email_unavailable` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |

**Response example** (`200`)

```json
{
  "ok": true
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/session-verification-id/email" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "jane.doe@example.com",
  "locale": "en",
  "consent": {
    "version": "nvid-email-v1",
    "accepted": true
  }
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-verification-id/email', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'jane.doe@example.com',
    locale: 'en',
    consent: {
      version: 'nvid-email-v1',
      accepted: true,
    },
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-verification-id/email",
    data=json.dumps({
        "email": "jane.doe@example.com",
        "locale": "en",
        "consent": {
            "version": "nvid-email-v1",
            "accepted": True,
        },
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Send the email-verification link for the address the customer typed (browser-facing) {#kycSessionEmailSend}

`POST /v1/kyc/session-email/send`

Called by the hosted verification page, never by a tenant backend. Available only while hosted email verification is enabled for the platform AND the session's effective `emailVerification` mode (fixed at mint) is `optional` or `required`; otherwise the byte-identical gated `404` (platform off) or `409 email_verification_not_enabled` (mode `off`). Sends one confirmation link to the address; the link is valid for 30 minutes or until the session ends, whichever is sooner. Opening the link changes nothing: the customer must press the button on the page it opens, because mail gateways prefetch links.
Limits per session: at most 5 accepted sends, at least 60 seconds between sends (`429 cooldown` with `retryAfterSec`, `429 send_limit_reached`), no send with less than 60 seconds of session left (`409 session_expiring`). Per client IP 20 sends an hour and per address 5 sends an hour (`429 Rate limit exceeded`). A provider failure rolls the send back (`502 email_send_failed`) so an outage does not spend the customer's quota. The response carries the masked address only.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | Yes | The address to verify (normalised server-side). At most 254 characters. |
| `locale` | string | No | One of the supported UI locales; the email is rendered in it (an unsupported value falls back to `en`). |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The link was sent. |  | `application/json` KycEmailVerificationStateEnvelope |
| `400` | `invalid_email`, or `invalid_request` (unreadable body). | `invalid_email`, `invalid_request` | `application/json` ErrorEnvelope |
| `401` | `invalid_session` — no session bearer, or one that does not resolve to a session. | `invalid_session` | `application/json` ErrorEnvelope |
| `403` | Missing or disallowed `Origin`. | `origin_not_allowed` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | `email_verification_not_enabled` (effective mode `off`), `already_verified` (this address is already confirmed; the body carries the current `emailVerification` state), `session_expiring` (under 60 seconds left), or `session_not_editable` (the session is past capture; the body carries its `status`). | `already_verified`, `email_verification_not_enabled`, `session_expiring`, `session_not_editable` | `application/json` ErrorEnvelope |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `413` | `body_too_large` — the request exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | `cooldown` (with `retryAfterSec`), `send_limit_reached`, or `Rate limit exceeded` (per-session, per-IP or per-address bucket; `retryAfter`). `Retry-After` is sent for the cooldown and rate-limit cases. | `cooldown`, `send_limit_reached` | `application/json` ErrorEnvelope |
| `502` | `email_send_failed` — the mail provider refused or failed; the send was rolled back. | `email_send_failed` | `application/json` ErrorEnvelope |
| `503` | `hosted_verify_not_configured`, `session_backend_unavailable`, or `invalid_request` (a transient lock or database fault); retry after the `Retry-After` seconds. | `hosted_verify_not_configured`, `invalid_request`, `session_backend_unavailable` | `application/json` ErrorEnvelope |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `emailVerification` | object (KycEmailVerificationState) | Yes | The customer-facing email-verification state returned by the hosted page's email routes and embedded in the snapshot as `emailVerification`. The address is always masked (`j***@example.com`). |
| `emailVerification.mode` | string | Yes | Allowed values: `optional`, `required`. |
| `emailVerification.email` | string, nullable | Yes | The masked address; `null` before one was entered. |
| `emailVerification.verified` | boolean | Yes |  |
| `emailVerification.verifiedAt` | integer (int64), nullable | Yes |  |
| `emailVerification.expiresAt` | integer (int64), nullable | Yes | Deadline of the live confirmation link (30 minutes or the session end, whichever is sooner); `null` when none is live. |
| `emailVerification.resendAvailableAt` | integer (int64), nullable | Yes | Earliest next send while the 60 second cooldown runs; `null` when a resend is allowed now or the cap is exhausted. |
| `emailVerification.sendsRemaining` | integer | Yes | Sends left of the 5 allowed per session. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "emailVerification": {
    "mode": "required",
    "email": "j***@example.com",
    "verified": false,
    "verifiedAt": null,
    "expiresAt": 1790764000000,
    "resendAvailableAt": 1790762260000,
    "sendsRemaining": 4
  },
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/session-email/send" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "email": "jane.doe@example.com",
  "locale": "en"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-email/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: 'jane.doe@example.com',
    locale: 'en',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-email/send",
    data=json.dumps({
        "email": "jane.doe@example.com",
        "locale": "en",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Poll the email-verification state (browser-facing) {#kycSessionEmailStatus}

`GET /v1/kyc/session-email/status`

Called by the hosted verification page while the customer is in their mail app; it builds no snapshot, just the `emailVerification` state (masked address). Same availability as `POST /v1/kyc/session-email/send`: the byte-identical gated `404` while the platform has the feature off, `409 email_verification_not_enabled` for a session whose mode is `off`.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The current state. |  | `application/json` KycEmailVerificationStateEnvelope |
| `401` | `invalid_session`. | `invalid_session` | `application/json` ErrorEnvelope |
| `403` | Missing or disallowed `Origin`. | `origin_not_allowed` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `409` | `email_verification_not_enabled` — the session's effective mode is `off`. | `email_verification_not_enabled` | `application/json` ErrorEnvelope |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `session_backend_unavailable` or `invalid_request` — transient; retry. | `invalid_request`, `session_backend_unavailable` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `emailVerification` | object (KycEmailVerificationState) | Yes | The customer-facing email-verification state returned by the hosted page's email routes and embedded in the snapshot as `emailVerification`. The address is always masked (`j***@example.com`). |
| `emailVerification.mode` | string | Yes | Allowed values: `optional`, `required`. |
| `emailVerification.email` | string, nullable | Yes | The masked address; `null` before one was entered. |
| `emailVerification.verified` | boolean | Yes |  |
| `emailVerification.verifiedAt` | integer (int64), nullable | Yes |  |
| `emailVerification.expiresAt` | integer (int64), nullable | Yes | Deadline of the live confirmation link (30 minutes or the session end, whichever is sooner); `null` when none is live. |
| `emailVerification.resendAvailableAt` | integer (int64), nullable | Yes | Earliest next send while the 60 second cooldown runs; `null` when a resend is allowed now or the cap is exhausted. |
| `emailVerification.sendsRemaining` | integer | Yes | Sends left of the 5 allowed per session. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "emailVerification": {
    "mode": "optional",
    "email": null,
    "verified": false,
    "verifiedAt": null,
    "expiresAt": null,
    "resendAvailableAt": null,
    "sendsRemaining": 5
  },
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl "https://collect.noxtica.com/v1/kyc/session-email/status" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-email/status', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
  },
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-email/status",
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
    },
    method="GET",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Confirm the emailed link (browser-facing, link token is the credential) {#kycEmailVerificationConfirm}

`POST /v1/kyc/email-verification/confirm`

Called by the page the emailed link opens, when the customer presses its one button. The link token (`kyev.<tenant>.<secret>`, 256-bit) is the only credential: no session bearer is read or accepted, and no session token is returned. Single use. A malformed, unknown, already used, expired, wrong-tenant or session-ended token all answer the identical `400 invalid_or_expired` (no oracle). The `Origin` must be on the hosted-verification allow-list. Per client IP 30 confirmations per 10 minutes. Available only while hosted email verification is enabled for the platform; otherwise the byte-identical gated `404`.

**Authentication:** none. This operation does not take a credential.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | string | Yes | The link token, `kyev.<tenant>.<secret>`. At most 512 characters. |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The address is now verified for that session. |  | `application/json` |
| `400` | `invalid_or_expired` (every token problem), or `invalid_request` (unreadable body). | `invalid_or_expired`, `invalid_request` | `application/json` ErrorEnvelope |
| `403` | Missing or disallowed `Origin`. | `origin_not_allowed` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `413` | `body_too_large` — the request exceeded this route's byte cap. | `body_too_large` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `temporarily_unavailable` — transient fault; retry after `Retry-After` seconds. | `temporarily_unavailable` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `requestId` | string | Yes |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/email-verification/confirm" \
  -H "Content-Type: application/json" \
  -d '{
  "token": "kyev.tenant_42.q3JkT0vN8mYb1xW6cE4aZ7uP2sH9dLfR5gKiA0oXyUw"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/email-verification/confirm', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    token: 'kyev.tenant_42.q3JkT0vN8mYb1xW6cE4aZ7uP2sH9dLfR5gKiA0oXyUw',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/email-verification/confirm",
    data=json.dumps({
        "token": "kyev.tenant_42.q3JkT0vN8mYb1xW6cE4aZ7uP2sH9dLfR5gKiA0oXyUw",
    }).encode(),
    headers={
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Read the public status of a Noxtica Verification ID (public, no authentication) {#kycVerificationIdStatus}

`GET /v1/kyc/verification-status`

Public lookup behind the status link `https://verify.<env>/v/<NVID>` that a person can share. **No bearer token.** Returns only the status of the ID, its dates and the display name of the business that requested the verification — never a name, document data, image, email address or tenant identifier. An NVID confirms *status for a business*, not identity, and is **not a credential**: it cannot be used to sign in or to skip a verification.
`status` is `revoked` (a Backoffice revocation; wins over everything), `pending` (the verification awaits a review decision), `expired` (365 days after completion) or `verified`. An unknown, erased or malformed ID — including one whose check symbol is wrong — answers the **same** `404 not_found` (no existence oracle). The `Origin` must be on the hosted-verification allow-list (`403 origin_not_allowed`); per-IP limit 60 requests per 60 seconds (`429`). Responses are `Cache-Control: no-store`. Only available while the NVID feature is enabled for the platform; otherwise the byte-identical gated `404`.

**Authentication:** none. This operation does not take a credential.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | query | string | Yes | The NVID, canonical form `NVID-XXXX-XXXX-XXXX-XXXX` (the collector also accepts the lower-case, unhyphenated and `O`/`I`/`L`-confusable spellings). |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | The ID's public status. |  | `application/json` KycVerificationIdStatusResponse |
| `403` | Missing or disallowed `Origin`. | `origin_not_allowed` | `application/json` ErrorEnvelope |
| `404` | `not_found` — unknown, erased or malformed ID (identical bodies), or the feature is not enabled for the platform (the gated `404` shape). | `not_found` | `application/json` |
| `429` | Per-IP limit exceeded (60 requests per 60 seconds). `Retry-After` carries the seconds. | `rate_limited` | `application/json` |
| `503` | Temporarily unavailable; retry after `Retry-After` seconds (2). | `temporarily_unavailable` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `200` | `Cache-Control` | string |  |
| `429` | `Retry-After` | integer |  |
| `503` | `Retry-After` | integer |  |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | Yes | Allowed values: `true`. |
| `verification` | object | Yes |  |
| `verification.id` | string | Yes | Canonical NVID. |
| `verification.status` | string | Yes | Public status of a Noxtica Verification ID. `revoked` wins over everything; `pending` = the verification awaits a review decision; `expired` = 365 days after completion; otherwise `verified`. Allowed values: `verified`, `pending`, `revoked`, `expired`. |
| `verification.verifiedAt` | integer (int64), nullable | Yes | Unix ms of completion; `null` while pending. |
| `verification.expiresAt` | integer (int64), nullable | Yes | Unix ms, completion + 365 days; `null` while pending. |
| `verification.revokedAt` | integer (int64), nullable | Yes | Unix ms of the revocation; `null` unless revoked. |
| `verification.requestedBy` | string, nullable | Yes | The requesting business's display name; `null` when none is configured. |

**Response example** (`200`)

```json
{
  "ok": true,
  "verification": {
    "id": "NVID-7K3M-Q9XA-T2RW-4HZD",
    "status": "verified",
    "verifiedAt": 1790762445000,
    "expiresAt": 1822298445000,
    "revokedAt": null,
    "requestedBy": "Example Bank"
  }
}
```

### Example request

**curl**

```bash
curl "https://collect.noxtica.com/v1/kyc/verification-status?id=NVID-7K3M-Q9XA-T2RW-4HZD"
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/verification-status?id=NVID-7K3M-Q9XA-T2RW-4HZD', {
  method: 'GET',
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/verification-status?id=NVID-7K3M-Q9XA-T2RW-4HZD",
    method="GET",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Mint an evidence-upload handle (browser-facing) {#kycSessionEvidenceHandle}

`POST /v1/kyc/session-evidence/handle`

Token-authenticated sibling of `POST /v1/kyc/evidence/handle`, delegating to the same evidence pipeline — no parallel evidence store.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Request body

Content type `application/json`, required.

Type: object.

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Handle minted. |  | `application/json` |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `403` | Missing/disallowed Origin, or an invalid/expired session token. | `invalid_request` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

Type: object.

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/session-evidence/handle" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-evidence/handle', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({}),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-evidence/handle",
    data=json.dumps({}).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Upload sealed ciphertext and finalize (browser-facing) {#kycSessionEvidenceFinalize}

`POST /v1/kyc/session-evidence/finalize`

Token-authenticated sibling of `POST /v1/kyc/evidence/finalize`.

**Authentication:** Bearer token (`SessionTokenAuth`).

### Request body

Content type `application/json`, required.

Type: object.

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Terminal inspection outcome. |  | `application/json` |
| `400` | The request failed validation. `error` is a route-specific code such as `invalid_request`; read the operation description for the exact values it can return. | `invalid_request` | `application/json` ErrorEnvelope |
| `403` | Missing/disallowed Origin, an invalid/expired session token, or a failed handle verification. | `invalid_request` | `application/json` ErrorEnvelope |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `410` | The session can no longer be used by the hosted verification page, in one of two byte-stable shapes told apart by `code`. `kyc_session_expired` (`error: session_expired`) — the session is past its effective `expiresAt` (the original mint TTL while unopened, or `openedAt` plus the 30-minute capture window once opened) and never reached a terminal/completed state. `kyc_session_cancelled` (`error: session_cancelled`) — the end customer cancelled the session via `POST /v1/kyc/session-cancel` (terminal status `cancelled_by_user`). The hosted verification page MUST stop polling on either response. | `kyc_session_cancelled`, `session_cancelled` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

Type: object.

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/session-evidence/finalize" \
  -H "Authorization: Bearer $NOXTICA_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/session-evidence/finalize', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_SESSION_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({}),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/session-evidence/finalize",
    data=json.dumps({}).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_SESSION_TOKEN']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Create a right-to-erasure request for an attested subject {#kycErasureSelfRequest}

`POST /v1/kyc/erasure/self-request`

Device-callable FR-15 erasure intake. The subject is resolved server-side from an opaque `attestationId` — a caller can never supply a `subjectId` directly (no cross-subject erasure oracle). Always resolves 200; a resolution miss, an inactive subject, or an infra fault all collapse to the SAME `{ok:true, requested:false, status:'subject_not_attested'}` shape.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `attestationId` | string | Yes |  |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Always 200 (fail-closed discipline). |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No | Allowed values: `true`. |
| `requested` | boolean | No |  |
| `status` | string | No | Allowed values: `subject_not_attested`, `requested`, `erasure_request_failed`. |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "requested": true,
  "status": "subject_not_attested",
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/erasure/self-request" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "attestationId": "att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/erasure/self-request', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    attestationId: 'att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/erasure/self-request",
    data=json.dumps({
        "attestationId": "att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Transactionally activate a proofed-unbound mobile capability {#kycActivate}

`POST /v1/kyc/activate`

Composes a signed attestation projection, an atomic edge consent receipt, and a typed device-identity proof into one transactional activation. Two opaque references only (`attestationId`, `receiptId`) — never a `subjectId` or `tenantId` (both are resolved server-side). Available only when mobile activation is enabled for your organisation (a separate enablement from `/v1/kyc/verify`); otherwise the route answers the `GatedNotFound` 404.

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:identity`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `attestationId` | string | Yes |  |
| `receiptId` | string | Yes |  |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | Always 200 (fail-closed discipline) — a false `activated:true` is never returned. |  | `application/json` |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical "V1 endpoint not found" response returned when the route's module is not enabled for your organisation — indistinguishable from a route that does not exist (anti-enumeration by design; do not treat this as proof the path is wrong). | `V1 endpoint not found` | `application/json` |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No | Allowed values: `true`. |
| `activated` | boolean | No |  |
| `reason` | string | No | Present when activated is false (e.g. an unresolvable/foreign/stale attestation or receipt). |
| `capability` | object | No | Present only when activated is true. |
| `capability.proofStatus` | string | No | Allowed values: `proofed_unbound`. |
| `capability.verificationLevel` | string | No | Allowed values: `verified`, `partial`, `unverified`, `inconclusive`. |
| `capability.receiptId` | string | No |  |
| `capability.activatedAt` | integer | No |  |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "activated": true,
  "reason": "user_requested",
  "capability": {
    "proofStatus": "proofed_unbound",
    "verificationLevel": "verified",
    "receiptId": "receipt_2f6a8b3c9d4e5f60718293a4b5c6d7e8",
    "activatedAt": 1
  },
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kyc/activate" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "attestationId": "att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70",
  "receiptId": "receipt_2f6a8b3c9d4e5f60718293a4b5c6d7e8"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kyc/activate', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    attestationId: 'att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70',
    receiptId: 'receipt_2f6a8b3c9d4e5f60718293a4b5c6d7e8',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kyc/activate",
    data=json.dumps({
        "attestationId": "att_9c6b2f7e4a3d4c8e9f1a2b3c4d5e6f70",
        "receiptId": "receipt_2f6a8b3c9d4e5f60718293a4b5c6d7e8",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

## Report one observed merchant transaction signal {#kymTransactionsReport}

`POST /v1/kym/transactions`

Notify-only. Records exactly one transaction signal that feeds scheduled alert-rule evaluation; this endpoint never calls a payment or execution API. The target `caseId` must already exist under KYM monitoring (created in the Backoffice KYB/KYM case surface).

**Authentication:** Bearer token (`ApiKeyAuth`), scope `write:kym`.

### Request body

Content type `application/json`, required.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `caseId` | string | Yes | At most 512 characters. |
| `idempotencyKey` | string | Yes | At most 255 characters. |
| `amountMinor` | integer | Yes | Amount in the currency's minor unit (e.g. cents). Minimum 0. |
| `currency` | string | Yes | Pattern `^[A-Z]{3}$`. |
| `counterpartyRef` | string, nullable | No |  |
| `occurredAt` | string (date-time) | Yes |  |

### Responses

| Status | Meaning | Error codes | Content |
| --- | --- | --- | --- |
| `200` | A replayed idempotencyKey — the original row (deduplicated). |  | `application/json` KymTransactionResponse |
| `201` | A new transaction-event row was recorded. |  | `application/json` KymTransactionResponse |
| `400` | `invalid_case_id` / `invalid_idempotency_key` / `invalid_amount` / `invalid_currency` / `invalid_occurred_at`. | `invalid_case_id`, `invalid_request` | `application/json` ErrorEnvelope |
| `401` | The `Authorization: Bearer sk_...` header is missing or malformed, or the key is unknown or revoked. The body is deliberately identical for every one of those causes. | `invalid_api_key` | `application/json` KycSessionError |
| `403` | The presented key lacks the required scope. | `API key does not have write:identity scope` | `application/json` |
| `404` | The byte-identical gated-route 404, OR `case_not_found` (the caseId does not exist for this tenant). | `invalid_request` | `application/json` ErrorEnvelope |
| `429` | Rate limit exceeded. Wait `Retry-After` seconds (also `retryAfter` in the body) before retrying; the response carries no other rate-limit headers. | `Rate limit exceeded` | `application/json` |
| `503` | `write_failed` — a persistence fault. | `invalid_request`, `write_failed` | `application/json` ErrorEnvelope |

**Response headers**

| Status | Header | Type | Description |
| --- | --- | --- | --- |
| `429` | `Retry-After` | integer | Seconds until the bucket allows another request. |

**Response fields** (`200`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No | Allowed values: `true`. |
| `eventId` | string | No |  |
| `deduplicated` | boolean | No |  |
| `requestId` | string | No |  |

**Response example** (`200`)

```json
{
  "ok": true,
  "eventId": "evt_3c9b2f7e4a3d4c8e9f1a2b3c4d5e6f70",
  "deduplicated": false,
  "requestId": "req-test"
}
```

**Response fields** (`201`, `application/json`)

| Field | Type | Always returned | Description |
| --- | --- | --- | --- |
| `ok` | boolean | No | Allowed values: `true`. |
| `eventId` | string | No |  |
| `deduplicated` | boolean | No |  |
| `requestId` | string | No |  |

**Response example** (`201`)

```json
{
  "ok": true,
  "eventId": "evt_3c9b2f7e4a3d4c8e9f1a2b3c4d5e6f70",
  "deduplicated": false,
  "requestId": "req-test"
}
```

### Example request

**curl**

```bash
curl -X POST "https://collect.noxtica.com/v1/kym/transactions" \
  -H "Authorization: Bearer $NOXTICA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "caseId": "example-caseId",
  "idempotencyKey": "example-idempotencyKey",
  "amountMinor": 0,
  "currency": "USD",
  "counterpartyRef": "example-counterpartyRef",
  "occurredAt": "2026-09-13T12:00:00.000Z"
}'
```

**Node.js** (global `fetch`, Node 18 or later)

```js
const response = await fetch('https://collect.noxtica.com/v1/kym/transactions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOXTICA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    caseId: 'example-caseId',
    idempotencyKey: 'example-idempotencyKey',
    amountMinor: 0,
    currency: 'USD',
    counterpartyRef: 'example-counterpartyRef',
    occurredAt: '2026-09-13T12:00:00.000Z',
  }),
});
console.log(response.status, await response.text());
```

**Python** (standard library)

```python
import json
import os
import urllib.request

request = urllib.request.Request(
    "https://collect.noxtica.com/v1/kym/transactions",
    data=json.dumps({
        "caseId": "example-caseId",
        "idempotencyKey": "example-idempotencyKey",
        "amountMinor": 0,
        "currency": "USD",
        "counterpartyRef": "example-counterpartyRef",
        "occurredAt": "2026-09-13T12:00:00.000Z",
    }).encode(),
    headers={
        "Authorization": f"Bearer {os.environ['NOXTICA_API_KEY']}",
        "Content-Type": "application/json",
    },
    method="POST",
)
with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())
```

_This page is generated from the Noxtica OpenAPI specification release `v0.4.0`: `kyc.yaml` version `1.2.0-2026-10-27` (SHA-256 `c91a695cfde0`). Do not edit it by hand._
