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.
Key points
- Events are signed notificationsEach delivery is an HTTPS POST signed with HMAC-SHA256 over the timestamp, a dot and the raw body. Verify the signature before you parse the body.
- Read the status before you actDelivery is at least once, order is not fixed and an event is not the decision. Deduplicate on X-Noxtica-Delivery, then read the session status.
- The signing secret is shown onceBackoffice shows the secret when you add or rotate an endpoint. Rotation stops the old secret immediately, with no overlap.
- Send test is not an identity eventSend test posts a submission.scored sample. It proves reachability and your signature code, not your identity handler.
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. The generic envelope and header reference is on Webhooks.
Prerequisites
- Identity verification provisioned for your organisation. See 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
- Open Backoffice → Integrations & API → Webhooks (
/integrations/webhooks). Stay on the “Manage” tab. The “Integration” tab holds the in-product guide. - Under “Add endpoint”, enter the “Endpoint URL”. Add a “Description (optional)” of up to 200 characters if you want one.
- Under “Events to deliver”, tick the identity events you need. Select at least one event.
- Select “Add endpoint” and complete the step-up prompt.
- 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.
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:
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:
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. 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. |
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. |
{
"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. |
{
"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. |
{
"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.
| Field | Meaning |
|---|---|
requestId | The id of the erasure request. It is not a subject id. |
outcome | Always completed. |
{
"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. |
{
"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:
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.
Register the endpoint
In Backoffice, add an HTTPS URL, tick the identity events you need and copy the signing secret when it appears.
Receive the delivery
Read the four X-Noxtica headers and the JSON envelope. Answer with a 2xx status and do the work afterwards.
Verify the signature
Hash the timestamp, a dot and the raw body with the endpoint secret. Compare in constant time and enforce your own timestamp tolerance.
Handle the event
Each identity event has its own fields. Match a session event to your customer by sessionId.
Deduplicate and read the status
Record X-Noxtica-Delivery, tolerate any order, and read the session status before you act.
Rotate, disable or delete
Use the endpoint buttons on the Manage tab. To change the events, delete the endpoint and add it again.
Verify it works
- 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. - 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. - 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”).
- 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.
deliveredmeans your endpoint answered2xx,deadmeans every retry failed, andterminalmeans a4xxor 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 — create sessions and read the status.
- Noxtica Verification ID — the
nvidfield on events and the status read. - Business and merchant verification —
kyb.entity.*andkym.*events. - Identity verification FAQ and troubleshooting — duplicates, ordering and missing events.
- Webhooks — the generic envelope, headers and signature reference.
- Errors — error codes and the
requestIdto quote.