Skip to main content
Authentication activity and its release-dependent webhook delivery are documented in Authentication events and webhooks. Do not register unlisted auth event types with the current destination API.

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. Polling GET /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. This chapter of the Didit Academy integration lesson picks up at the webhooks overview. The integration Academy lesson covers webhooks from 3:12: adding a destination, picking events, and the signing secret you will verify below.
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’s subscribed_events array. There is no wildcard — list every family you want to receive — and you can spread events across multiple destinations. The values in this table are the complete set of subscribable events - there is no 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.
The Business Console > API & Webhooks > Try Webhook menu sends fully-formed payloads for every one of these events (approved, declined, in-review, Unicode names, KYC/KYB, entity, activity, and transaction). Use it to validate your endpoint before going live.

Payload shape

Envelope

Every webhook shares the same envelope. Fields are always serialised with sort_keys=True and compact separators (, / :) so the same payload reproduces the same signature on both sides. For session events (status.updated, data.updated on regular and business sessions) the envelope additionally carries:

Session envelope example

The decision object is the same schema returned by GET /v3/session/{id}/decision/. For the canonical per-feature field reference (every property on id_verifications[], aml_screenings[], etc.) see Data models. We deliberately do not duplicate the per-feature field tables here so the schema only lives in one place.

V3 plural arrays (important)

Inside decision, 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):
Each item carries a 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". E-signature results are in signatures[], present only when the workflow has an E-signature step: one item per signer, with the signer, the code confirmation, the certificate, the timestamp, each document’s hashes and download paths, and the evidence PDF. Each document also carries language (nullable string), source_revision (UUID) and translation_id (nullable UUID). These identify the version selected when the attempt started: a complete translation for the signer language (session language when unset), otherwise the original. Later edits or language changes do not replace that copy. There is no separate signature event: status.updated tells you when to read them. See the E-signature report. Bank Verification results are in bank_verifications[], one item per Bank Verification step. Each item’s method says how the account was verified: open_banking (bank sign-in), account_lookup (account check), or a statement or microdeposits fallback, and null until an account is evaluated. When the person chose Continue without your bank, continued_without_bank is true and continuation says why. method is then none, unless an attempt was evaluated before: see When the person cannot verify their bank. An account verified by account check reads no financial data, so financial_data.not_collected lists the balances, transactions or income the step asked for. See Bank verification methods.

Investigator block

A V3 decision for a user or business session also carries investigator: the session’s latest Investigator review, or null when it never had one. 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. User Verification decisions also carry a single biometric_consent object: whether the session required explicit biometric consent, the trigger, the status of the answer and its evidence. status: "pending" with reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK" means a native SDK below 4.10.0 captured a face without the consent step. See Biometric consent.

ID Verification method keys

Every id_verifications[] item says which ID Verification method produced it, so a handler can branch on the evidence it actually got: Existing handlers keep working untouched: a document session sets verification_method to document and leaves the rest null. Field-by-field types are in Data models.

Approved wallet session (status.updated)

Entity event payloads

Entity statuses are 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:
action_required is null, or the pending user action (type wallet_ownership or verification_session, with its url) described in Required user actions. New transactions default to 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.created and transaction.status.updated are only delivered for live applications. Sandbox Travel Rule exchanges do emit travel_rule.status.updated with environment: sandbox.

KYB registry search callback

The asynchronous POST /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 with X-Didit-Unsigned-Callback: true instead; validate it by matching request_id against the id returned by the search request.
  • The event name lives in event_type (not webhook_type), and there is no application_id or environment.
  • timestamp and created_at are epoch integers, like other webhooks but unlike the search endpoint’s ISO created_at.
The callback follows the same retry policy as destination webhooks (retries re-stamp 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’s secret_shared_key. Three signature headers are sent so you can pick the one that survives your stack: Headers Didit always sends: For 24 hours after a destination’s signing secret is rotated, deliveries also carry X-Signature-Previous, X-Signature-V2-Previous and X-Signature-Simple-Previous (see Signing secret rotation).

Signing secret rotation

You rotate a destination’s signing secret in the Business Console. For 24 hours after a rotation, every delivery to that destination carries three more headers, made with the secret that was replaced: A handler that accepts a match on either a signature header or its -Previous header keeps verifying while you deploy the new secret: the old secret matches the -Previous header, and the new one matches the regular header. The code samples below do this. Deploy the new secret within the 24 hours, because after that only the new secret verifies. previous_secret_expires_at on GET /v3/webhook/destinations/{destination_uuid}/ tells you when the 24 hours end. Retries and resends are signed again with the destination’s current secret when they are sent, and carry the -Previous headers only while the 24 hours last. Rotating again within the 24 hours retires the oldest secret at once: only the secret that was current until the new rotation keeps verifying through the -Previous headers, for 24 hours from the new rotation. The -Previous headers exist only for webhook destinations. The application-level secret_shared_key that signs the legacy webhook_url has no overlap: rotating it with rotate_secret_key on PATCH /v3/webhook/ invalidates the old secret immediately.

X-Signature-Simple is deprecated

X-Signature-Simple authenticates the envelope, not the payload. Its canonical string covers only timestamp, session_id, status and webhook_type. It does not cover decision — nor resubmit_info, metadata, vendor_data or event_id. A request whose decision has been rewritten while those four fields are left untouched still passes X-Signature-Simple verification.It is weaker still on the events that carry no session_id at all. Transaction events (transaction_id, txn_id, amount, score) and entity events (vendor_user_id, vendor_business_id) sign "{timestamp}::{status}:{webhook_type}", which does not identify which transaction or entity the event is about — two different transactions reported with the same status in the same second carry the same X-Signature-Simple. travel_rule.status.updated, workflow.compliance_changed and transaction_rule_suggestions.created events carry no status either, so they sign "{timestamp}:::{webhook_type}", for example "{timestamp}:::travel_rule.status.updated".If X-Signature-Simple is the only header you check, treat everything outside those four fields as untrusted and re-fetch the record from the API — GET /v3/session/{session_id}/decision/ for a session, GET /v3/transactions/{transaction_id}/ for a transaction — which is the server of record.
This is a compatibility constraint, not something we can patch in place. The four-field canonical string is a wire contract: every receiver that verifies X-Signature-Simple recomputes exactly those four fields, so changing what the header signs would silently break all of them on the signature path. Covering the body also requires a specified canonical form of the JSON that both sides reproduce byte for byte, and Didit already ships one as X-Signature-V2. Rather than add a second body-covering variant with its own canonicalization rules, the header stays exactly as it is and is deprecated in favour of the existing full-payload variants. What that means for you:
  • Nothing changes on the wire. X-Signature-Simple keeps being sent on every delivery, computed the same way. Existing verifiers keep working, and we will announce any removal well in advance.
  • New integrations must use X-Signature-V2. It is middleware-safe and covers the whole payload.
  • If you cannot move off X-Signature-Simple, verify it, then fetch the record from the API rather than reading it out of the webhook body.

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 the deprecated X-Signature-Simple falls back to signing only a small, parser-independent string when even that fails. Because it signs only that short string and never the body, it cannot vouch for the body.
  1. Try X-Signature-V2 first. Re-encode JSON.parse(body) with sorted keys and Unicode preserved, then HMAC-SHA256(secret, canonical) and timingSafeEqual against the header.
  2. If V2 fails and you can read raw bytes, try X-Signature against HMAC-SHA256(secret, rawBody).
  3. Only if both fail, fall back to the deprecated X-Signature-Simple against HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}"). This authenticates the envelope alone: treat the decision in the body as untrusted and re-fetch it from GET /v3/session/{session_id}/decision/ before you act on it.
At each step, when the matching -Previous header is present, accept a match on either header (see Signing secret rotation). In all cases, reject any request older than 5 minutes (abs(now - X-Timestamp) > 300) to defend against replays.

Retry policy

If your endpoint responds with a 5xx 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.
After the second retry the delivery is dropped. Each delivery attempt (initial + retries) is logged as a separate entry in the Business Console under the destination’s Deliveries tab, so you can replay or inspect any attempt. Each attempt in a destination’s delivery log carries an attempt number: 1 for the first delivery of an event, and one more than the attempt it repeats for each retry or resend. Automatic retries go to the URL the first attempt was sent to, even if you changed the destination’s url since. A manual resend goes to the destination’s current URL, so fixing a URL also fixes the resend. Other delivery rules to know:
  • 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.
  • A destination url containing a raw backslash, a control character, or more than one @ in the authority is rejected as malformed - such a URL cannot be created and an existing one cannot be redelivered or replayed. Use a plain https://host/path URL with at most one userinfo section (for basic-auth style webhook URLs).
  • 2xx is treated as success. Timeouts and connection failures count as retryable (they surface as 504/503 in the delivery log); 3xx and 4xx responses other than 404 are not retried.
  • On retry Didit recomputes signatures with a fresh X-Timestamp, so the timestamp/signature pair always lines up.
  • Retries and resends are signed with the destination’s current signing secret at the time they are sent.

Delivery order

Didit does not guarantee the order in which webhooks reach you. Each delivery is queued on its own and a retry arrives minutes after the first attempt, so a status.updated for Approved can reach you before an earlier one for In Progress. Build your receiver for that:
  • Treat a webhook as a signal that something changed. When the order matters, call GET /v3/session/{session_id}/decision/ and act on the current status.
  • Never let an older event overwrite newer state: compare the event’s created_at with the last one you stored for that record, and ignore an older one.
  • Key repeats on event_id, which stays the same across retries.
Didit queues the events of a Travel Rule transfer in a set order, described under Travel Rule exchange status changed, but a retry can still change the order in which they arrive.

Setting up a destination

You can manage destinations from the Business Console > API & Webhooks or via the Management API.
1

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.
2

Store the secret_shared_key

The create response returns the destination’s secret_shared_key. Store it with your other server secrets: it belongs to this destination only and is the input to all three HMAC-SHA256 signature variants. GET /v3/webhook/destinations/{destination_uuid}/ returns it again to API-key callers and to console users who can read webhooks.
3

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.
4

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.
5

Iterate with Try Webhook

Use the Try Webhook console scenarios to drive your endpoint through approved, declined, in-review, KYB, entity, activity, transaction, and Travel Rule payloads before flipping production traffic on.
Didit webhook testing feature in the Business Console

Send test webhooks directly from the Business Console to validate your endpoint before going live.

You can create as many destinations as you need. A common pattern is one destination per consumer: e.g. KYC events → your auth service, KYB events → your compliance ops service, transactions → your fraud queue. Each destination has its own secret, version, and subscription list, so a rotated secret or a bad deploy on one consumer does not affect the others.

Code samples

To ensure the security of your webhook endpoint, verify the authenticity of incoming requests using the destination’s secret_shared_key.
Use X-Signature-V2. It is the variant most resilient to middleware re-encoding, and it fully authenticates the body (including decision).
If you can read the raw bytes before any parser touches them, the original X-Signature variant works the same way but signs the raw body. We keep it in the legacy section below for completeness.
PHP’s JSON defaults do not reproduce the canonical form. Four of them have to be overridden, and missing any single one changes the signed bytes so the digest never matches: The empty-object trap is the one that bites hardest. Didit payloads routinely carry empty maps such as risk_view.countries.risk_scores, risk_view.crimes.risk_scores, and risk_view.custom_list, so an associative round-trip breaks the signature on ordinary approved sessions rather than on some rare edge case.
Two more things worth knowing:
  • Whole-valued floats need no special handling in PHP. Didit serialises them as integers (100.0 → 100), and PHP’s json_encode writes a whole float the same way, so no shortenFloats step is required, unlike the Node.js sample above.
  • Keep serialize_precision at -1 (the default since PHP 7.1). If your host overrides it to 17, json_encode writes 87.42 as 87.420000000000002 and every X-Signature-V2 check fails.
If you cannot get the canonical form to match, verifySignatureRaw above is the simpler fallback: Laravel’s $request->getContent() returns the untouched bytes, so X-Signature works as long as nothing in front of PHP re-encodes the body. That is exactly the case X-Signature-V2 is designed to survive, which is why V2 is tried first.

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, travel_rule) and send a fully-formed webhook to your destination URL.
  • The travel_rule scenario sends the travel_rule.status.updated payload documented below: transaction_id, txn_id, travel_rule_status, rail and direction, with no verification-session status.
  • 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 confirm X-Signature-V2 works through your middleware. Test deliveries carry sample data and are marked with an extra X-Didit-Test-Webhook: true header (and a (Test) suffix on the User-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.
For end-to-end smoke tests, run a real verification through a test workflow in your own application — the resulting webhooks use the same envelope, headers, and retry policy as the Try Webhook scenarios.

Examples

Approved KYC session (status.updated)

Declined session with warnings (status.updated)

Business session approved (status.updated, KYB)

Transaction created (transaction.created)

Travel Rule exchange status changed (travel_rule.status.updated)

travel_rule_status carries one of the 13 exchange statuses documented in Travel Rule. rail is INTERNAL, TRP, GTR, TRUST, VERIFYVASP, SYGNA, or EMAIL (empty string until a rail is selected, if none could be resolved, and always on sunrise registrations), or TEST_VASP for a sandbox transfer the Didit Test VASP answered, and direction is INBOUND or OUTBOUND. A sandbox event carries "environment": "sandbox", and a TEST_VASP event also carries test_vasp: true and test_vasp_scenario (the scenario that answered). When Didit creates a transaction for a Travel Rule transfer (a POST /v3/transactions/ call, an after-deposit sunrise registration, an inbound TRP inquiry, or the INTERNAL-rail inbound leg), it sends the transaction.created event for that transaction first and the transfer’s travel_rule.status.updated events after it. A rule that changes the transaction’s status during that step can also send transaction.status.updated before transaction.created, and a sunrise registration that attaches to a deposit transaction you already created sends no transaction.created. Each webhook is delivered and retried on its own, so a retried event can still arrive after a later one: key your handler on transaction_id, and re-read the transaction when you need its current state. A transfer sent over TRUST never gets a COMPLETED event, because TRUST sends back nothing Didit can read: it stays at AWAITING_COUNTERPARTY until the confirmation deadline and then ends EXPIRED. Over GTR, VerifyVASP and Sygna Bridge, Didit reads the counterparty’s answer from the network and the event follows it (see Travel Rule networks).

Workflow compliance changed (workflow.compliance_changed)

transition.from and transition.to are one of compliant, gaps_detected, pending_change, unknown_coverage or not_evaluated. severity is actionable when gaps were detected, indicative for a pending change and informative when gaps were resolved. Didit sends one event per workflow that changed.

Transaction rule suggestions created (transaction_rule_suggestions.created)

kind is ADD for a new rule, TWEAK for a change to an existing rule and DISABLE to turn one off. rule_id is null for ADD. open_count is the number of suggestions still open after the run, new ones included. The rule-suggestion settings turn this webhook on or off; see Transaction webhooks.

Investigator events (investigator.*)

The twelve investigator.* events use the same envelope (event_id, webhook_type, timestamp, created_at, application_id, environment) and the same signature headers as every other event. They carry ids and figures only: never a name, a reason a reviewer typed, a narrative or anything the end user wrote. They have no session_id; the item they are about is named in item.
provenance says which version of the Investigator wrote the report:
  • engine is the Investigator engine’s version: an opaque eight-character id. It changes whenever the engine changes, so two reports with the same engine were written by the same engine.
  • agent_version is the agent’s version.
  • policy_version is the version of your own policy the report followed.
  • model_id is a deprecated alias that carries the same value as engine. Read engine instead.
  • prompt_hash is no longer sent.
item.kind names the kind of item (a session, a business session, a transaction or a case) and item.id its id. Timestamps ending in _at are ISO 8601 strings. Subscribe to these events by listing the exact strings in subscribed_events, like any other event.

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.

Legacy: V2 webhook format

The fields below only apply when a destination is pinned to webhook_version: "v2". An even older "v1" pin also exists (a third, singular decision shape) for destinations created before V2 — contact support before relying on it. New integrations should use V3 (the default), which is documented above and uses plural arrays.
The per-feature objects themselves are the same in both versions: a V2 destination receives exactly the per-hit match_score, risk_score, score_breakdown, risk_view and review_status and the aggregate score that V3 does (see AML report). Pinning to V2 changes where the AML report sits in decision, not what it contains. If a downstream consumer still needs V2 shapes, create a separate destination pinned to webhook_version: "v2" and keep your new consumers on V3.
  • Webhook destinations API — list, create, update, delete destinations and inspect delivery logs.
  • Create webhook destination — request/response schema including the destination’s secret_shared_key.
  • Data models — canonical schema for every per-feature object embedded in decision.
  • Decision schema — full field list for the decision object embedded in session webhooks.
  • Verification statuses — exact status values and transitions referenced above.
  • Rate limiting — limits that apply when polling GET /v3/session/{id}/decision/ as a fallback.
Last modified on November 11, 2026