Overview
Webhooks are how Didit pushes real-time updates to your backend whenever a verification session, business session, vendor user, vendor business, or transaction changes state. Subscribing one or more destinations to the events you care about lets you react the moment a result is ready — usually within seconds of the verifying user finishing their flow. Webhooks are the recommended integration pattern. PollingGET /v3/session/{id}/decision/ is supported as a fallback, but it is slower, costs more requests, and skips events like data.updated, transaction.status.updated, and entity-level changes that are only emitted through webhooks. Treat webhook delivery as the source of truth and only poll on cold start or for reconciliation.
Cloudflare / WAF users. Didit delivers webhooks from the static IP
18.203.201.92 with User-Agent: DiditWebhook/2.0 +https://didit.me. If your edge blocks unknown clients, allow this IP in Security > WAF > Tools > IP Access Rules and choose Allow for the receiving hostname.Event types
Use the exact value in a destination’ssubscribed_events array. There is no wildcard — list every family you want to receive — and you can spread events across multiple destinations.
| Event | Resource | When it fires |
|---|---|---|
status.updated | KYC session or KYB business session | A session’s status changes (Not Started → In Progress → Approved / Declined / In Review / Abandoned / Resubmitted). KYB payloads include session_kind: "business" and business_session_id. |
data.updated | KYC session or KYB business session | Verification data is edited after creation (manual reviewer corrections to KYC, POA, NFC, KYB registry, KYB documents, etc.). |
user.status.updated | User entity | A consolidated user’s status changes between ACTIVE, FLAGGED, and BLOCKED (console action, rule action, blocklist). Includes previous_status. |
user.data.updated | User entity | A consolidated user’s profile, counters, metadata, or approved-identifiers change. Includes changed_fields and a changes map of previous / current values. |
business.status.updated | Business entity | A consolidated business’s status changes between ACTIVE, FLAGGED, and BLOCKED (console action, rule action, blocklist). Includes previous_status. |
business.data.updated | Business entity | A consolidated business’s profile, registration data, counters, metadata, or aggregate verification fields change. Includes changed_fields and changes. |
activity.created | Activity timeline | Reserved for the activity timeline feed. The value is accepted in subscribed_events and covered by the Try Webhook tester, but Didit does not currently deliver activity.created for live traffic. |
transaction.created | Transaction Monitoring | A transaction is created and its initial rules verdict is ready. Includes transaction_id, txn_id, status, score, severity, amount, currency, direction. |
transaction.status.updated | Transaction Monitoring | A transaction’s status changes after rule evaluation, analyst action, remediation, blocklist matches, provider updates, or API/console actions. |
session.status.updated, kyc.completed, or similar. The only other delivery Didit makes is the unsigned kyb.registry_search.resolved callback, which goes to the per-request webhook_url you pass to POST /v3/kyb/search/, never to a destination.
Payload shape
Envelope
Every webhook shares the same envelope. Fields are always serialised withsort_keys=True and compact separators (, / :) so the same payload reproduces the same signature on both sides.
| Field | Type | Notes |
|---|---|---|
event_id | uuid | Stable identifier for the event. The same event_id is sent to every destination the event fans out to and is reused on retries — key your idempotency on it. |
webhook_type | string | One of the event types above. |
timestamp | int (Unix seconds) | When Didit dispatched the webhook. Used by signature verification. Refreshed on each retry. |
created_at | int (Unix seconds) | When the underlying record was last updated. |
application_id | uuid | The application that owns the destination. |
environment | "live" | "sandbox" | Whether the event came from a live or sandbox session/application. |
status | string | Present on every delivered event (including *.data.updated). Session events use the exact verification status labels (e.g. "Approved", "Kyc Expired"); entity events use ACTIVE / FLAGGED / BLOCKED; transaction events use APPROVED / IN_REVIEW / DECLINED / AWAITING_USER. |
status.updated, data.updated on regular and business sessions) the envelope additionally carries:
| Field | Type | Notes |
|---|---|---|
session_id | uuid | The user-verification or business-session UUID. |
business_session_id | uuid | Only on KYB. Equal to session_id. |
session_kind | "business" | Only on KYB events; lets a generic handler branch. |
workflow_id | uuid | Workflow used to create the session, when set. |
workflow_version | int | Workflow version. |
vendor_data | string | Your internal identifier, when supplied at session creation. |
vendor_business_id | string | Only on KYB sessions, when supplied. |
metadata | object | Whatever metadata you attached at session creation, with internal dedup keys stripped. |
trigger | string | Optional. Why the webhook fired: "manual_review" (reviewer edits from the console), "manual_step_update" (a reviewer changed a feature status), or "ongoing_monitoring" (AML ongoing-monitoring refresh). |
decision | object | Present when status is Approved, Declined, In Review, or Abandoned. Mirrors the V3 session decision. |
resubmit_info | { nodes_to_resubmit, reasons } | Present when status is Resubmitted (no decision). |
Session envelope example
V3 plural arrays (important)
Insidedecision, every per-feature result is a plural array so a single workflow can include several instances of the same feature (for example, multiple ID checks across documents):
node_id matching the workflow graph plus a per-feature status. Do not read the legacy V2 singular fields (id_verification, nfc, liveness, face_match, phone, email, poa, aml, ip_analysis, database_validation); they only appear if a destination is explicitly pinned to webhook_version: "v2".
Document-collection features — KYB documents and Document AI — do not have a dedicated plural array; they appear in the decision features[] list as { "feature": "DOCUMENT_AI", "node_id": "…" } (one entry per node). See Document AI.
Entity event payloads
ACTIVE, FLAGGED, or BLOCKED. user.data.updated and business.data.updated keep the status field and add a changed_fields array plus a changes map of { field: { previous, current } }. business.* events use vendor_business_id instead of vendor_user_id.
Transaction event payloads
Transaction webhooks ride on the same fan-out infrastructure and use the same envelope, but identify the transaction instead of a session:APPROVED. If a rule, blocklist, or workflow changes the status during creation, the initial transaction.created payload reflects that final status. transaction.status.updated carries the same shape with the updated status. Transaction statuses are APPROVED, IN_REVIEW, DECLINED, or AWAITING_USER; severity is UNKNOWN / LOW / MEDIUM / HIGH / CRITICAL and direction is INBOUND / OUTBOUND. Transaction webhooks are only delivered for live applications — sandbox transactions never fan out.
KYB registry search callback
The asynchronousPOST /v3/kyb/search/ flow delivers one extra payload that is not a destination event: when you pass a webhook_url to the search request, Didit POSTs a kyb.registry_search.resolved callback to that URL once the registry resolves. It differs from destination webhooks in three ways:
- It is unsigned — no
X-Signature*headers. Didit marks it withX-Didit-Unsigned-Callback: trueinstead; validate it by matchingrequest_idagainst the id returned by the search request. - The event name lives in
event_type(notwebhook_type), and there is noapplication_idorenvironment. timestampandcreated_atare epoch integers, like other webhooks but unlike the search endpoint’s ISOcreated_at.
timestamp and stay unsigned). See the KYB Registry API for the full candidate-list schema.
Signature verification
Every webhook is signed with HMAC-SHA256 using the destination’ssecret_shared_key. Three signature headers are sent so you can pick the one that survives your stack:
| Header | What is signed | Middleware-safe | When to use |
|---|---|---|---|
X-Signature-V2 | Sorted, Unicode-preserved compact JSON (ensure_ascii=False) | Yes | Recommended — works even if your framework re-encodes the body. |
X-Signature | Exact raw bytes of the request as Didit transmitted them (sort_keys, ensure_ascii=True) | No | Only when you can read the raw body before any parser touches it. |
X-Signature-Simple | "{timestamp}:{session_id}:{status}:{webhook_type}" | Yes | Fallback that authenticates the envelope only — it does not authenticate decision. Pair it with TLS pinning or destination IP allow-listing if you rely on it. |
| Header | Example | Meaning |
|---|---|---|
Content-Type | application/json | Body is JSON. |
User-Agent | DiditWebhook/2.0 +https://didit.me | Identifies Didit’s webhook worker. |
X-Timestamp | 1774970000 | Unix epoch seconds when the webhook was dispatched. Reject if abs(now - X-Timestamp) > 300. |
X-Signature / X-Signature-V2 / X-Signature-Simple | hex digests | HMAC-SHA256 of the body or canonical string above. |
Why three variants?
Some web frameworks (Express body parsers, Django middleware, API gateways) silently re-encode JSON before your handler reads it. If the original body contained"José" and your middleware rewrites it to "José", the bytes change even though the data is identical. X-Signature then fails. X-Signature-V2 is computed from a canonical JSON form (sort keys, compact separators, unescaped Unicode) that almost every middleware reproduces deterministically — and X-Signature-Simple falls back to signing only a small, parser-independent string when even that fails.
Recommended verification order
- Try
X-Signature-V2first. Re-encodeJSON.parse(body)with sorted keys and Unicode preserved, thenHMAC-SHA256(secret, canonical)andtimingSafeEqualagainst the header. - If V2 fails and you can read raw bytes, try
X-SignatureagainstHMAC-SHA256(secret, rawBody). - If both fail, fall back to
X-Signature-SimpleagainstHMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")— and treat anydecisiondata as untrusted unless you can re-fetch it from the API.
abs(now - X-Timestamp) > 300) to defend against replays.
Retry policy
If your endpoint responds with a5xx or 404, Didit retries up to 2 times with exponential backoff:
- 1st retry — about 1 minute after the initial failure.
- 2nd retry — about 4 minutes after the 1st retry.
- The outbound HTTP request times out after 5 seconds.
- Didit blocks deliveries to private / localhost URLs as an SSRF guard and never follows redirects. Use a public HTTPS endpoint that answers directly.
2xxis treated as success. Timeouts and connection failures count as retryable (they surface as504/503in the delivery log);3xxand4xxresponses other than404are not retried.- On retry Didit recomputes signatures with a fresh
X-Timestamp, so the timestamp/signature pair always lines up.
Setting up a destination
You can manage destinations from the Business Console > API & Webhooks or via the Management API.Create the destination
Call
POST /v3/webhook/destinations/ (or click Add destination in the console) with a label, public url, webhook_version ("v3"), and a subscribed_events array containing every event family this endpoint should receive. At least one event is required and no wildcard exists.Store the secret_shared_key
The create response returns the destination’s
secret_shared_key. Store it now — it is the only secret you will ever see for this destination, scoped to this destination, and is the input to all three HMAC-SHA256 signature variants.Implement the endpoint
Expose a public HTTPS
POST route, read the raw body, verify the signature (preferring X-Signature-V2), check the timestamp window, then dispatch on webhook_type. Return 2xx as soon as you have queued the work — do heavy processing asynchronously.Confirm receipts
Watch the Deliveries tab on the destination. Healthy webhooks return
200 within a second or two. 5xx/404 triggers Didit’s retry policy; persistent failures are dropped after the second retry.Iterate with Try Webhook
Use the Try Webhook console scenarios to drive your endpoint through approved, declined, in-review, KYB, entity, activity, and transaction payloads before flipping production traffic on.

Code samples
To ensure the security of your webhook endpoint, verify the authenticity of incoming requests using the destination’ssecret_shared_key.
Node.js / Express (recommended: X-Signature-V2)
X-Signature variant works the same way but signs the raw body. We keep it in the legacy section below for completeness.
Python / FastAPI (recommended: X-Signature-V2)
PHP / Laravel (recommended: X-Signature-V2)
Testing
You do not need to run a full verification to exercise your endpoint.- From Business Console > API & Webhooks > Try Webhook, pick a scenario (e.g.
approved_full_features,declined_face_mismatch,in_review_aml_hit,business_session_approved,transaction_created,activity_created,user_status_updated) and send a fully-formed webhook to your destination URL. - Every scenario is signed with the same three signature headers and canonical encoding as production traffic, including unicode test data (
approved_kyc_with_unicode) so you can confirmX-Signature-V2works through your middleware. Test deliveries carry sample data and are marked with an extraX-Didit-Test-Webhook: trueheader (and a(Test)suffix on theUser-Agent) so you can keep them out of production state. - Replay any historic delivery from a destination’s Deliveries tab if you need to debug a regression.
Examples
Approved KYC session (status.updated)
Declined session with warnings (status.updated)
Business session approved (status.updated, KYB)
Transaction created (transaction.created)
User entity status changed (user.status.updated)
Resubmitted session
Legacy: X-Signature with raw body
X-Signature is still sent on every delivery; it is HMAC-SHA256 over the exact bytes Didit transmits (json.dumps(..., sort_keys=True, separators=(",", ":"))). Use it only when you can read the raw body before any middleware modifies it.
- Node.js
- Python
Legacy: V2 webhook format
| Aspect | V2 | V3 |
|---|---|---|
| Signature headers | X-Signature, X-Signature-V2, X-Signature-Simple (all three are sent regardless of version) | Same |
| Decision field names | Singular objects (id_verification, liveness, face_match, …) | Plural arrays (id_verifications, liveness_checks, face_matches, …) |
| Multiple feature instances | Not supported | Supported via per-feature arrays with node_id |
webhook_version: "v2" and keep your new consumers on V3.
Related
- Webhook destinations API — list, create, update, delete destinations and inspect delivery logs.
- Create webhook destination — request/response schema including the once-returned
secret_shared_key. - Data models — canonical schema for every per-feature object embedded in
decision. - Decision schema — full field list for the
decisionobject embedded in session webhooks. - Verification statuses — exact
statusvalues and transitions referenced above. - Rate limiting — limits that apply when polling
GET /v3/session/{id}/decision/as a fallback.