Webhooks & Callbacks
Webhooks let the platform push events to the Company's backend instead of it polling. They carry the result of a verification, events a workflow sends on its way, and changes operators make to stored records. Webhooks are optional — a Company with no endpoints configured can read results from the Customer API instead.
What gets delivered
| Event type | Fires when | Data tier |
|---|---|---|
workflow.completed | A verification ends with an accept or reject decision. Carries the result and the verified customer data. | All tiers |
workflow.event | A workflow reaches an emit_event step. Carries a Company-defined event name and payload. | All tiers |
record.action | A back-office operator changes a stored record — accept, reject, reset to review, blocklist, anonymize, merge, resolve as unique, edit fields. | Stored tier only |
A verification that ends in review sends no workflow.completed. When an operator later decides it, the decision arrives as a record.action (Accept or Reject).
Changes made through the Customer API do not produce record.action events — your backend made them, so it already knows.
Configuration
Endpoints are registered by an Administrator in Integration Settings → Webhooks, per environment.
- One or more endpoints. Each is an
https://URL on a public host; private, loopback and link-local addresses are refused. - A signing secret per endpoint. It is shown once, when you add the endpoint or rotate its secret. Copy it then; it cannot be displayed again. Rotating replaces it immediately, so update your receiver first.
- Active or paused. A paused endpoint receives nothing until it is enabled again.
- Every endpoint receives every event type. Branch on
eventTypein your receiver and acknowledge the types you do not use. - Include selfie in webhook — whether
workflow.completedcarries the selfie crop. On by default.
A verification sends webhooks only if the tenant had at least one active endpoint when its session was created.
Webhooks replace the earlier single Callback URL and its VERIFICATION_SUCCEEDED callback. An existing callback URL was carried over as an endpoint, with a signing secret generated by the platform that has never been shown. To keep receiving results:
- In Integration Settings → Webhooks, Rotate secret on the carried-over endpoint and store the new secret on your receiver.
- Verify the
X-IDV-Signatureheader. - Read the result from the
workflow.completedenvelope instead of the oldevent/databody. The customer fields keep their names; they now sit underpayload.customer.
Envelope
Every delivery is an HTTP POST with a JSON body in a common envelope:
{
"eventId": "0195a1b2-c3d4-7890-abcd-ef1234567891",
"eventType": "record.action",
"tenantId": "550e8400-e29b-41d4-a716-446655440000",
"occurredAt": "2026-01-01T12:00:00Z",
"payload": { }
}
| Field | Description |
|---|---|
eventId | Unique per event. Retries and replays of the same event keep it — use it to de-duplicate. |
eventType | workflow.completed, workflow.event or record.action. |
tenantId | Your tenant. |
occurredAt | When the event happened (ISO 8601, UTC). |
payload | Event-specific, see below. |
Fields are added over time, so ignore unknown properties rather than rejecting the payload. The full schemas are in the Customer API reference under Webhooks.
workflow.completed
The result of a verification, with the verified customer data. This is how a Company that does not read records back over the Customer API — including every tenant on the session-based tier — receives the data.
{
"eventId": "…",
"eventType": "workflow.completed",
"tenantId": "…",
"occurredAt": "2026-01-01T12:00:00Z",
"payload": {
"workflowId": "onboarding_v1",
"workflowRunId": "…",
"sessionToken": "…",
"result": "VERIFIED",
"timestamp": 1767268800000,
"customer_id": "…",
"digitalIdentityId": "…",
"customer": {
"fullName": "Jane Doe",
"firstName": "Jane",
"lastName": "Doe",
"dateOfBirth": "1990-05-17",
"documentNumber": "…",
"address": { "fullAddress": "…" },
"selfie": "…"
},
"trustFactors": [
{ "name": "age_verification", "state": "accepted", "mandatory": true }
]
}
}
| Field | Description |
|---|---|
workflowId | Name of the workflow that ran. |
workflowRunId | Identifier of the run. |
sessionToken | The session you created, for correlation only. It expires with the session and cannot be used to fetch data. |
result | VERIFIED or REJECTED. |
timestamp | Completion time, Unix epoch milliseconds. |
customer_id | External identifier of the Customer, on the stored tier when the run produced or updated one. |
digitalIdentityId | Identifier of the stored Digital Identity. null when no record was stored, together with digitalIdentityIdUnavailableReason. |
customer | Verified data read from the document — the fields are listed under Website Redirect → Customer data. Empty fields are omitted. |
trustFactors | Each trust factor of the run: name, state (accepted, review, rejected, skipped), mandatory, and reason: "missing_input" when the state comes from missing input. Absent when the run evaluated none. |
Only the first delivery attempt carries the selfie. A retry or a replay sends customer.selfie as null with customer.selfieUnavailableReason.
workflow.event
Sent by an emit_event step each time a run reaches it. The event name and data are whatever the workflow defines.
{
"eventType": "workflow.event",
"payload": {
"workflowId": "onboarding_v1",
"workflowRunId": "…",
"eventName": "onboarding.decided",
"stepKey": "emit_event",
"stepOrder": 8,
"outcomeName": "accept_unique",
"data": { "outcome": "accept_unique", "trust": "accept", "channel": "web" }
}
}
| Field | Description |
|---|---|
eventName | The step's event parameter. |
stepKey · stepOrder | Which step sent it. |
outcomeName | The matched outcome. Present only when a decision has already matched one. |
data | The step's resolved payload. Absent when the step has none. |
A workflow event is not transactional with the outcome: it can arrive before the records the outcome creates exist.
record.action
One event per operator action in the back office — per button, or per Save for field edits. Payloads are diff-only: they say what changed, not the whole record. Fetch the record from the Customer API when you need it.
{
"eventType": "record.action",
"payload": {
"recordType": "DigitalIdentity",
"recordId": "…",
"action": "Accept",
"actor": { "operatorId": "…", "operatorEmail": "operator@company.example", "role": "Operator" },
"transition": { "trustFactorState": { "from": "review", "to": "accepted" } }
}
}
| Field | Description |
|---|---|
recordType | DigitalIdentity or Customer. |
recordId | External identifier of the record, as used by the Customer API. |
action | One value of the closed list below. |
actor | Who acted: operatorId, operatorEmail, role (Operator, Supervisor, Administrator). |
transition | What changed — see below. |
comment | The operator's reason, on ResetToReview. |
| Record | action | transition |
|---|---|---|
| Digital Identity | Accept · Reject | trustFactorState from the previous decision to accepted / rejected |
| Digital Identity | ResetToReview | trustFactorState to review; also deduplicationState to unresolved when it had been resolved |
| Digital Identity | ResolveAsUnique | deduplicationState to unique |
| Digital Identity | MergeWithCustomer · MergeWithBlocklist | deduplicationState to merged |
| Digital Identity | PutOnBlocklist | trustFactorState to rejected |
| Digital Identity | Anonymize | trustFactorState to anonymized |
| Customer | EditField | fieldChanges[] — one entry per changed field (name, given_name, surname, place_of_birth, date_of_birth) with from and to |
| Customer | PutOnBlocklist | fieldChanges[] with status from active to blocked |
| Customer | Anonymize | none |
trustFactorState values are the Digital Identity decisions in lower case (pending, incomplete, review, accepted, rejected, anonymized); deduplicationState values are unresolved, unique and merged.
The event is queued in the same transaction as the change, so every committed action produces exactly one event.
Verifying authenticity
Each delivery carries an X-IDV-Signature header of the form sha256=<hex>: an HMAC-SHA256 of the raw request body, keyed with the endpoint's signing secret. Recompute it and compare before trusting the payload.
Use the secret exactly as shown in the dashboard, as a text string — do not hex-decode it. Compute the digest over the bytes you received; re-serializing parsed JSON changes the bytes and the signature will never match.
const { createHmac, timingSafeEqual } = require('node:crypto');
// rawBody: the request body exactly as received (string or Buffer).
function isAuthentic(rawBody, signatureHeader, secret) {
if (!signatureHeader) return false;
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
import hashlib
import hmac
def is_authentic(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
if not signature_header:
return False
expected = "sha256=" + hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header, expected)
Reject a request whose signature does not match. A 4xx reply is final: the platform does not retry it and lists it as a failed delivery.
Delivery guarantees
| Property | Behavior |
|---|---|
| Off the critical path | Webhooks are sent in the background after the decision. A slow or unreachable endpoint never delays the verification result returned to the user. |
| Acknowledgement | Any 2xx response acknowledges the event. Each attempt waits up to 30 seconds for a response. |
| At-least-once | Failed attempts are retried with exponential backoff — first after about 1 minute, doubling each time, at most 4 hours apart — for up to 24 hours. |
| Retryable | 5xx, 408, 429 and connection failures are retried. Every other 4xx is treated as a permanent refusal and is not retried. |
| Idempotency | De-duplicate on eventId — the same event can arrive more than once, from a retry or a replay. |
| Ordering | Not guaranteed, even for one record. Use occurredAt if order matters. |
| Failed deliveries | A delivery that is refused with a non-retryable 4xx, or that is still failing after 24 hours, is listed as a failed delivery and can be replayed — see below. |
Failed deliveries and replay
Integration Settings → Webhooks → Failed webhook deliveries lists every event that was not delivered, with the endpoint URL, event type, eventId, the last HTTP status, and when it failed. The Webhooks tab shows a banner while any are waiting.
Replay sends the event again, with its original eventId, and the normal retry schedule applies. A replay is offered to every active endpoint that accepts the event, so an endpoint that already received it may see it again — de-duplicating on eventId covers this. A replayed event differs from the original only where its content has expired:
workflow.completedhas nosessionToken;sessionTokenExpiredistrueinstead. The selfie isnull.- If the person was anonymized after the delivery failed, the personal data is removed from the stored event and
payload.personalDataRemovedistrue. Only the identifiers of the run or action remain.
See also
- Integration Settings → Webhooks — register endpoints and secrets
- Manifest reference → Emit event step
- Customer API — fetch full records referenced by an event
- Digital Identity Lifecycle