---
title: "Identity verification events and webhooks"
description: "Register a signed HTTPS endpoint in Backoffice, verify every delivery, and handle each identity verification event: when it fires, its fields, retries and duplicates."
canonical_url: "https://noxtica.com/docs/identity-verification-webhooks/"
markdown_url: "https://noxtica.com/docs/identity-verification-webhooks/index.md"
last_updated: 2026-10-27
published: 2026-10-27
section: "identity-verification"
section_title: "Identity Verification"
site: "Noxtica"
language: "en"
source: markdown
---

# Identity verification events and webhooks

Identity verification sends signed HTTPS events to endpoints you register in Backoffice, so your server does not have to poll for outcomes. A Backoffice administrator registers the endpoint. A backend engineer verifies each delivery, removes duplicates and reads the session status before acting.

## What it is

Availability: Controlled availability. Events belong to identity verification, which your account team provisions for your organisation. Events marked "when enabled for your organisation" depend on settings your account team controls.

An event is a notification, not a decision. Each delivery is a `POST` with a JSON envelope and four headers, signed with the signing secret of the endpoint that receives it. The six identity events are:

| Event | Fires when | Delivery |
| ----- | ---------- | -------- |
| `kyc.session.completed` | A hosted session reaches `completed`, `unverified` or `error`. A session in `review_pending` fires it later, when a reviewer approves or overrides the case. | Retried |
| `kyc.session.cancelled` | The customer cancels on the hosted page. Subscribe to it explicitly. When enabled for your organisation. | Retried |
| `kyc.verification.completed` | A verification reaches a final state, whether it came from a hosted session or from a direct API call. | Retried |
| `kyc.erasure.completed` | An erasure request reaches `completed`. A `partial` erasure sends nothing. When erasure is enabled for your organisation. | Retried |
| `kyc.document.expiry_warning` | A verified document is 90, 60 or 30 days from expiry, or has expired. When enabled for your organisation. | Single attempt |
| `kyc.reverification.due` | A scheduled re-verification becomes due. When enabled for your organisation. | Single attempt |

"Retried" means up to 5 more attempts (step 5). "Single attempt" means one try with no retry. A session that expires sends no event. Read its status to see `expired`. Events for business and merchant verification (`kyb.entity.*`, `kym.*`) are on [Business and merchant verification](https://noxtica.com/docs/identity-verification-business/). The generic envelope and header reference is on [Webhooks](https://noxtica.com/docs/api/webhooks/).

## Prerequisites

- Identity verification provisioned for your organisation. See [Identity verification integration](https://noxtica.com/docs/identity-verification-integration/).
- A Backoffice user with the administrator role. The Webhooks page is restricted to administrators, and every change asks for fresh step-up verification (MFA).
- A public HTTPS endpoint. Localhost, private addresses, internal names and URLs that carry a user name or password are refused. A custom HTTPS port is allowed.
- Room for the endpoint: an organisation can register up to 25 endpoints, and each URL can be registered once.
- A server-side secret store for the signing secret. The secret is shown once.
- An endpoint that answers within 5 seconds. That is the default wait for each attempt; bounds can change.

## Steps

### 1. Register the endpoint

1. Open Backoffice → Integrations & API → Webhooks (`/integrations/webhooks`). Stay on the "Manage" tab. The "Integration" tab holds the in-product guide.
2. Under "Add endpoint", enter the "Endpoint URL". Add a "Description (optional)" of up to 200 characters if you want one.
3. Under "Events to deliver", tick the identity events you need. Select at least one event.
4. Select "Add endpoint" and complete the step-up prompt.
5. Copy the secret from "Copy your signing secret now" into your server secret store, then select "I've copied it". Backoffice never shows it again.

The Backoffice label for each identity event:

| Event | Label in "Events to deliver" |
| ----- | ---------------------------- |
| `kyc.session.completed` | Hosted KYC session completed |
| `kyc.session.cancelled` | Hosted KYC session cancelled by the end user |
| `kyc.verification.completed` | KYC verification completed |
| `kyc.erasure.completed` | KYC erasure completed |
| `kyc.document.expiry_warning` | KYC document expiry warning |
| `kyc.reverification.due` | KYC re-verification due |

Subscriptions are per event. An endpoint receives the events you ticked, and an event type added to the platform later is not added to an existing endpoint.

### 2. Receive and acknowledge deliveries

Every delivery is an HTTPS `POST` with `Content-Type: application/json` and these headers:

| Header | Value |
| ------ | ----- |
| `X-Noxtica-Signature` | `sha256=` followed by the lowercase hex HMAC-SHA256 signature |
| `X-Noxtica-Timestamp` | The time of this attempt in epoch milliseconds. It changes on every retry. |
| `X-Noxtica-Event` | The event type, equal to `type` in the body |
| `X-Noxtica-Delivery` | The delivery id, equal to `id` in the body and to `data.deliveryId`. It stays the same across retries. |

The body is one envelope:

| Field | Meaning |
| ----- | ------- |
| `id` | The delivery id. Treat it as an opaque string. |
| `type` | The event type. |
| `created_at` | When the event occurred, in epoch milliseconds. It equals `data.occurredAt`. |
| `tenant_id` | Your organisation id. |
| `data` | The event fields in step 4. `schemaVersion` (`"1"`) sits inside `data`, not on the envelope. |

Answer with a `2xx` status and do the work afterwards. Noxtica does not follow redirect responses.

### 3. Verify the signature

The signature is an HMAC-SHA256 over `<timestamp>.<raw body>`, keyed with the endpoint's signing secret as UTF-8 text. Verify it before you parse or trust anything in the body.

- Use the body exactly as received. Do not parse and re-serialise it.
- Compare in constant time.
- Check the timestamp header, not `created_at`. Every retry is signed again with a fresh timestamp.
- Noxtica enforces no timestamp window. Enforce your own; five minutes is a sensible tolerance.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_MS = 5 * 60 * 1000;

export function verifyNoxticaWebhook({ secret, timestamp, signature, rawBody, now = Date.now() }) {
  const sentAt = Number(timestamp);
  if (!Number.isSafeInteger(sentAt) || Math.abs(now - sentAt) > TOLERANCE_MS) return false;

  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const provided = String(signature).replace(/^sha256=/, '').toLowerCase();

  const expectedBytes = Buffer.from(expected);
  const providedBytes = Buffer.from(provided);
  return expectedBytes.length === providedBytes.length && timingSafeEqual(expectedBytes, providedBytes);
}
```

Save the verifier above as `verify-noxtica-webhook.mjs`. This receiver keeps the raw body, deduplicates and acknowledges quickly:

```js
import { createServer } from 'node:http';
import { verifyNoxticaWebhook } from './verify-noxtica-webhook.mjs';

const seen = new Set(); // replace with a durable store shared by all your instances

createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks).toString('utf8');

  const valid = verifyNoxticaWebhook({
    secret: process.env.NOXTICA_WEBHOOK_SECRET,
    timestamp: req.headers['x-noxtica-timestamp'],
    signature: req.headers['x-noxtica-signature'],
    rawBody,
  });
  if (!valid) return res.writeHead(401).end();

  const deliveryId = req.headers['x-noxtica-delivery'];
  if (!seen.has(deliveryId)) {
    seen.add(deliveryId);
    void handleEvent(JSON.parse(rawBody)); // step 5
  }
  res.writeHead(204).end();
}).listen(3000);
```

The same check in Python:

```python
import hashlib
import hmac
import time

TOLERANCE_MS = 5 * 60 * 1000

def verify_noxtica_webhook(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    if not (timestamp.isascii() and timestamp.isdigit()):
        return False
    if abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
        return False

    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    provided = signature.removeprefix("sha256=").lower()
    return hmac.compare_digest(expected, provided)
```

### 4. Handle each event

Every identity `data` object carries `schemaVersion` (`"1"`), `occurredAt` (epoch milliseconds) and `deliveryId`. No identity event carries your `subjectRef`, a name or a document value. `reasonCodes` come from a closed catalog; see [Server API reference](https://noxtica.com/docs/api/reference/kyc/). Ignore fields you do not recognise.

#### `kyc.session.completed`

The delivery id is `<sessionId>:<webhookId>`.

| Field | Meaning |
| ----- | ------- |
| `sessionId` | The `sessionId` you stored when you created the session. |
| `status` | `completed`, `unverified` or `error`. |
| `verificationId` | The verification case id, or `null` when no verification ran. |
| `reasonCodes` | An array of closed reason codes. It can be empty. |
| `nvid` | Optional. The Noxtica Verification ID, when one was issued. See [Noxtica Verification ID](https://noxtica.com/docs/identity-verification-id/). |
| `email`, `emailVerified` | Optional. The address the customer entered and whether they confirmed it. Present when email verification is enabled for your organisation and an address was entered. It is personal data. |

```json
{
  "id": "<sessionId>:<webhookId>",
  "type": "kyc.session.completed",
  "created_at": 1790762445000,
  "tenant_id": "<tenant-id>",
  "data": {
    "schemaVersion": "1",
    "sessionId": "<sessionId>",
    "status": "completed",
    "verificationId": "<verification-id>",
    "reasonCodes": [],
    "occurredAt": 1790762445000,
    "deliveryId": "<sessionId>:<webhookId>"
  }
}
```

A finished capture currently waits in `review_pending`. This event follows when a reviewer approves or overrides the case in Backoffice, normally within a few minutes of the decision.

#### `kyc.session.cancelled`

The delivery id is `<sessionId>:<webhookId>`. It fires once, when the customer's cancel moves the session to `cancelled_by_user`. A repeated cancel, a refused cancel and an expiry send nothing. Persist the reason from the event: the status read does not return it.

| Field | Meaning |
| ----- | ------- |
| `sessionId` | The session id. |
| `status` | Always `cancelled_by_user`. |
| `cancelReason` | `not_now`, `privacy_concern`, `no_document`, `technical_problem`, `other`, or `null`. |
| `cancelledAt` | When the customer cancelled, in epoch milliseconds. |
| `email`, `emailVerified` | Optional, as above. |

```json
{
  "id": "<sessionId>:<webhookId>",
  "type": "kyc.session.cancelled",
  "created_at": 1790762300000,
  "tenant_id": "<tenant-id>",
  "data": {
    "schemaVersion": "1",
    "sessionId": "<sessionId>",
    "status": "cancelled_by_user",
    "cancelReason": "not_now",
    "cancelledAt": 1790762300000,
    "occurredAt": 1790762300000,
    "deliveryId": "<sessionId>:<webhookId>"
  }
}
```

#### `kyc.verification.completed`

The delivery id is `<verificationId>:<webhookId>`. A hosted session runs a verification too, so an endpoint subscribed to both events can receive this one and `kyc.session.completed` for the same session. Correlate by `verificationId`. The order between the two is not fixed.

| Field | Meaning |
| ----- | ------- |
| `verificationId` | The verification id. It equals `session.verificationId` on the status read. |
| `verdict` | `verified`, `unverified` or `error`. |
| `reasonCodes` | An array of closed reason codes. It can be empty. |
| `idType` | The document type asserted on the request. |

```json
{
  "id": "<verificationId>:<webhookId>",
  "type": "kyc.verification.completed",
  "created_at": 1790762440000,
  "tenant_id": "<tenant-id>",
  "data": {
    "schemaVersion": "1",
    "verificationId": "<verificationId>",
    "verdict": "unverified",
    "reasonCodes": ["<reason-code>"],
    "idType": "passport",
    "occurredAt": 1790762440000,
    "deliveryId": "<verificationId>:<webhookId>"
  }
}
```

#### `kyc.erasure.completed`

It fires when an erasure request reaches `completed`. It deliberately carries no subject identifier, so the notice cannot re-link to the person. Noxtica does not delete verification details or evidence automatically. An administrator deletes verifications in Backoffice → Identity Verification → KYC Verifications, which does not send this event, or works an erasure request in KYC Erasure, where a request is approved by an administrator other than the requester. See [Identity verification data](https://noxtica.com/docs/identity-verification-data/).

| Field | Meaning |
| ----- | ------- |
| `requestId` | The id of the erasure request. It is not a subject id. |
| `outcome` | Always `completed`. |

```json
{
  "id": "<deliveryId>",
  "type": "kyc.erasure.completed",
  "created_at": 1790900000000,
  "tenant_id": "<tenant-id>",
  "data": {
    "schemaVersion": "1",
    "requestId": "<erasure-request-id>",
    "outcome": "completed",
    "occurredAt": 1790900000000,
    "deliveryId": "<deliveryId>"
  }
}
```

#### `kyc.document.expiry_warning` and `kyc.reverification.due`

A scheduled job sends both, when enabled for your organisation. The delivery id is `<scheduleRowId>:<webhookId>`. Match `verificationId` to the original verification. Each threshold fires once and is attempted once, so a failed attempt is not retried. Reconcile from your own records if you miss one.

| Field | Event | Meaning |
| ----- | ----- | ------- |
| `verificationId` | both | The verification whose document is expiring, or that is due again. |
| `alertType` | expiry | `expiry_90d`, `expiry_60d`, `expiry_30d` or `expiry_expired`. |
| `documentExpiresAt` | expiry | When the document expires or expired, in epoch milliseconds. |
| `dueAt` | re-verification | When the re-verification became due, in epoch milliseconds. |

```json
{
  "id": "<scheduleRowId>:<webhookId>",
  "type": "kyc.document.expiry_warning",
  "created_at": 1798538000000,
  "tenant_id": "<tenant-id>",
  "data": {
    "schemaVersion": "1",
    "verificationId": "<verificationId>",
    "alertType": "expiry_30d",
    "documentExpiresAt": 1801130000000,
    "occurredAt": 1798538000000,
    "deliveryId": "<scheduleRowId>:<webhookId>"
  }
}
```

### 5. Deduplicate, tolerate order and read the status

Delivery is at least once. Noxtica answers each outcome as follows:

| Your response | What happens |
| ------------- | ------------ |
| `2xx` | Delivered. |
| `4xx`, including `408` and `429` | Final. Not retried. Return a `4xx` when you mean it. |
| `3xx`, `5xx`, a timeout or a network error | Retried, up to 5 more attempts. The spacing is not published. |
| All retries fail | Marked `dead`. The endpoint stays enabled and Noxtica does not send the delivery again. |
| The URL is no longer a public HTTPS target | Refused and not retried. |

- Deduplicate on `X-Noxtica-Delivery`. Record it before you act.
- Do not rely on order. Two events for the same session can arrive in either order.
- There is no redelivery button and no replay API. To catch up, read the state: for hosted sessions, call the status read, and for a verification, open it in Backoffice → Identity Verification → KYC Verifications.
- Treat the event as a prompt, and the status read as the authority:

```js
async function handleEvent(event) {
  if (event.type !== 'kyc.session.completed') return;
  const response = await fetch(`https://collect.noxtica.com/v1/kyc/sessions/${event.data.sessionId}`, {
    headers: { Authorization: `Bearer ${process.env.NOXTICA_SERVER_KEY}` },
  });
  const { session } = await response.json();
  // Act on session.status. The "completed" status is the sole approval.
}
```

### 6. Rotate, disable or delete an endpoint

On the "Manage" tab of Backoffice → Integrations & API → Webhooks, each endpoint has these buttons:

- "Rotate secret" asks you to confirm "Rotate signing secret?". The current secret stops working immediately, with no overlap, and the new one is shown once. Update your receiver straight away. Retries still waiting are signed with the new secret.
- "Disable" stops deliveries to the endpoint and keeps it listed. "Enable" turns it back on.
- "Delete" removes the endpoint; deliveries to it stop immediately.

The tab has no editor for the event list of an existing endpoint. To change the events, delete the endpoint and add it again; the new endpoint has a new secret.

## Verify it works

1. On the endpoint row, select "Send test". Backoffice posts one signed sample, once, and shows "Test delivery succeeded" with the status and time. The row then shows the last status, for example `ok`.
2. Confirm that your receiver passed the signature check. The sample has the type `submission.scored`. It cannot produce an identity event, so it proves reachability and your signature code, not your identity handler.
3. Test the handler with a signed sample of your own for each identity event, or finish a hosted session from your server or from Backoffice → Identity Verification → KYC Invitations ("Create test session").
4. For a session or verification event, open Backoffice → Identity Verification → KYC → KYC Processes, select "View" on the process, and read the "Webhook deliveries" table. Its columns are Event, State, Attempts and Created. `delivered` means your endpoint answered `2xx`, `dead` means every retry failed, and `terminal` means a `4xx` or a refused URL ended it. The endpoint row's last status reflects "Send test" and non-identity events, not these deliveries.

## Troubleshooting

| Symptom | Cause | Fix |
| ------- | ----- | --- |
| "Send test" succeeds but no identity event arrives | The test sends `submission.scored`. The endpoint may not be subscribed to the identity event, may be disabled, or the event is not enabled for your organisation. | Check "Events to deliver", select "Enable" if needed, and ask your account team about gated events. |
| Signature check fails | The body was parsed and re-serialised, the secret is old after a rotation, the timestamp is read in seconds, or `created_at` was used instead of the header. | Hash the raw body, use the current secret, and use `X-Noxtica-Timestamp` in milliseconds. |
| Identity events never arrive for an endpoint that was created long ago | The endpoint was created before encrypted secret storage. Identity events are not delivered to it, and the delivery ends as `terminal`. | Select "Rotate secret" once, then update your receiver. |
| Events stop after you return `429` | Any `4xx` is final and is never retried. | Return `2xx` and queue the work. |
| An event arrives minutes after the reviewer's decision | Review decisions are sent on a periodic schedule. | Wait a few minutes. Read the status for the current state. |
| No event for `review_pending` or `expired` | Neither state sends an event. | Read the session status. |
| The same event arrives twice | Delivery is at least once, and an unconfirmed delivery can be sent again. | Deduplicate on `X-Noxtica-Delivery`. |
| A delivery shows `dead` | Every retry failed. | Fix the endpoint, then read the state from the status read. |
| "Send test" shows `blocked` or `http_0` | The URL is not a public HTTPS target. | Use a public HTTPS URL without credentials in it. |
| "That URL is already registered as a webhook endpoint" | The organisation has the URL already. | Use the existing endpoint, or delete it before you add it again. |
| "Webhook endpoints are limited to 25 per tenant" | The endpoint limit is reached. | Delete an endpoint you no longer use. |

## Related

- [Identity verification integration](https://noxtica.com/docs/identity-verification-integration/) — create sessions and read the status.
- [Noxtica Verification ID](https://noxtica.com/docs/identity-verification-id/) — the `nvid` field on events and the status read.
- [Business and merchant verification](https://noxtica.com/docs/identity-verification-business/) — `kyb.entity.*` and `kym.*` events.
- [Identity verification FAQ and troubleshooting](https://noxtica.com/docs/identity-verification-faq/) — duplicates, ordering and missing events.
- [Webhooks](https://noxtica.com/docs/api/webhooks/) — the generic envelope, headers and signature reference.
- [Errors](https://noxtica.com/docs/api/errors/) — error codes and the `requestId` to quote.
