Guide to Didit Webhooks: Features and Best Practices
Didit webhooks tell your backend when an identity check has a result: a user's verification session is approved, declined or sent to review, a business passes KYB, a transaction is flagged, or Didit's Investigator agent makes a decision. If you use Didit for KYC, KYB or transaction monitoring, webhooks are how results reach your onboarding and compliance systems, and Didit treats them as the source of truth rather than polling.
This guide covers how Didit destinations work, which of the three signature headers to verify and why, the events and payloads you will receive, and how to handle Didit's short retry window in production.
What are Didit webhooks?
Didit sends webhooks to destinations. A destination is a URL with its own list of subscribed events, its own payload version and its own signing secret, the secret_shared_key. You can create as many destinations as you like, which makes one destination per consumer a natural pattern: verification events to your onboarding service, transactions to your fraud queue.
Every delivery is a JSON POST with the event name in webhook_type. Didit signs each delivery three ways, all with HMAC-SHA256 and the same key, and sends all three headers every time:
X-Signaturesigns the exact bytes Didit sent.X-Signature-V2signs a canonical re-encoding of the JSON (sorted keys, compact separators, Unicode left unescaped), so it still verifies if your framework re-encoded the body.X-Signature-Simplesigns only{timestamp}:{session_. Didit has deprecated it because it does not cover theid}:{status}:{webhook_ type} decisionor anything else in the body.
An X-Timestamp header carries the dispatch time in Unix seconds. It is not part of the X-Signature input.
Didit webhook features
| Feature | Details |
|---|---|
| Configuration | Business Console (API & Webhooks) or POST /v3/webhook/destinations/ |
| Subscription model | A required subscribed_events list per destination. No wildcard |
| Event field | webhook_type in the body |
| Signature headers | X-Signature (raw body), X-Signature-V2 (canonical JSON), X-Signature-Simple (deprecated) |
| Scheme | HMAC-SHA256, hex digest |
| Secret | secret_shared_key, one per destination, shown once at creation |
| Timestamp | X-Timestamp, Unix seconds, refreshed on every retry. Didit recommends rejecting anything older than 300 seconds |
| Payload versions | v3 (default), v2 and an older v1, pinned per destination |
| Timeout | 5 seconds |
| Retries | Up to 2, about 1 minute and then about 4 minutes later, on 5xx, 404, timeouts and connection failures |
| Redirects | Never followed |
| Source IP | 18.203.201.92, with User-Agent: DiditWebhook/2.0 +https://didit.me |
Common events
| Area | Events | Fires when |
|---|---|---|
| Sessions | status.updated, data.updated | A KYC or KYB session changes status, or a reviewer edits its data |
| Users | user.status.updated, user.data.updated | A consolidated user moves between ACTIVE, FLAGGED and BLOCKED, or their profile changes |
| Businesses | business.status.updated, business.data.updated | The same for a consolidated business |
| Transactions | transaction.created, transaction.status.updated | A transaction is created with its initial verdict, or its status changes |
| Travel Rule | travel_ | A Travel Rule exchange changes status |
| Investigator | investigator.report.ready, investigator.decision.automated, investigator.decision.overridden, investigator.decision.contested, and eight more | Didit's Investigator writes a report, decides, is overridden, raises a question, and so on |
| Activity | activity.created | Accepted in subscriptions and the tester, but not delivered for live traffic today |
Didit says this list is the complete set of subscribable events. Session status values are labels such as Approved, Declined, In Review, Abandoned and Resubmitted. Entity events use ACTIVE, FLAGGED and BLOCKED, transactions use APPROVED, IN_REVIEW, DECLINED and AWAITING_USER, and investigator.* events carry no status at all.
Every payload shares an envelope of event_id, webhook_type, timestamp, created_at, application_id, environment (live or sandbox) and, except for Investigator events, status. Session events add session_id, workflow_id, vendor_data, metadata and, once a decision exists, decision. Here is Didit's session envelope example:
{
"event_id": "9c0c8b8a-1111-4222-9333-444444444444",
"webhook_type": "status.updated",
"timestamp": 1774970000,
"created_at": 1774969994,
"application_id": "11111111-2222-3333-4444-555555555555",
"environment": "live",
"sandbox_scenario": null,
"session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"status": "Approved",
"workflow_id": "66666666-7777-8888-9999-000000000000",
"workflow_version": 4,
"vendor_data": "user_42",
"metadata": { "tier": "premium" },
"decision": { "...": "see /sessions-api/retrieve-session" }
}
event_id is the same on every retry and on every destination the event fans out to, so it is the key for deduplication. timestamp is refreshed on each retry. In v3 payloads, the decision uses plural arrays such as id_verifications and aml_screenings; v2 destinations get singular objects instead. The version is not echoed in the payload, so keep track of which version each destination is pinned to.
One other delivery looks similar but isn't a destination event. If you pass a webhook_url to POST /v3/kyb/search/, Didit posts a kyb.registry_ callback there. It is unsigned, carries X-Didit-Unsigned-Callback: true, and names its event in event_type instead of webhook_type. Validate it by matching request_id against the search you started, and send it to a different URL from your signed destination.
Setting up Didit webhooks
- In the Business Console, go to API & Webhooks and add a destination, or call
POST /v3/webhook/destinations/with alabel, your public HTTPSurl,webhook_version: "v3"and asubscribed_eventsarray. - Store the
secret_shared_keystraight away, for example asDIDIT_WEBHOOK_SECRET. Didit only shows it at creation. - Use Try Webhook in the console to send fully formed test payloads for approved, declined, in-review, KYB, entity and transaction scenarios. Test deliveries are signed like real ones and carry an extra
X-Didit-Test-Webhook: trueheader, so you can keep them out of production data. - Watch the destination's Deliveries tab. Healthy deliveries return
200within a second or two.
Didit blocks private and localhost URLs and does not follow redirects, so register the final public HTTPS URL.
Securing Didit webhooks
Which header you verify depends on whether you can read the raw body. If you can, verify X-Signature: a hex HMAC-SHA256 of the exact bytes received, keyed with secret_shared_key. If a framework or middleware re-encodes the JSON before your code sees it, verify X-Signature-V2 against a canonical re-encoding instead. Don't rely on X-Signature-Simple.
Here is X-Signature verification. The X-Timestamp check is optional and off by default: Didit recommends a 300-second window, but a delivery that is retried or replayed later, by Didit or by a queue in front of your service, would fail it.
const crypto = require('crypto');
const express = require('express');
// 0 skips the X-Timestamp check; set a positive number of seconds (for example 300) to enable it.
const TIMESTAMP_TOLERANCE_SECONDS = Number(process.env.DIDIT_TIMESTAMP_TOLERANCE_SECONDS || 0);
function verifyDiditSignature(rawBody, signatureHeader, timestampHeader, secret) {
if (!signatureHeader || !secret) return false;
if (TIMESTAMP_TOLERANCE_SECONDS > 0) {
const sent_at = Number(timestampHeader);
const now = Math.floor(Date.now() / 1000);
if (!Number.isFinite(sent_at) || Math.abs(now - sent_at) > TIMESTAMP_TOLERANCE_SECONDS) return false;
}
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/didit', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.DIDIT_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
const is_valid = verifyDiditSignature(
req.body,
req.get('x-signature'),
req.get('x-timestamp'),
secret,
);
if (!is_valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.event_id, route on event.webhook_type, then process async.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import os
import time
TIMESTAMP_TOLERANCE_SECONDS = int(os.environ.get("DIDIT_TIMESTAMP_TOLERANCE_SECONDS", "0"))
def verify_didit_signature(raw_body: bytes, signature_header, timestamp_header, secret: str) -> bool:
if not signature_header or not secret:
return False
if TIMESTAMP_TOLERANCE_SECONDS > 0:
if not timestamp_header or not timestamp_header.isdigit():
return False
if abs(time.time() - int(timestamp_header)) > TIMESTAMP_TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_didit_signature(raw_body, request.headers.get("x-signature"),
# request.headers.get("x-timestamp"), os.environ["DIDIT_WEBHOOK_SECRET"])
For X-Signature-V2, parse the body, re-serialise it with keys sorted at every level, compact separators (, and :) and non-ASCII characters left as they are (ensure_ascii=False in Python), then HMAC that string. Didit's docs include full samples for Node, Python and PHP.
Make Didit webhooks production-ready. Hookdeck Event Gateway verifies Didit's
X-Signatureagainst the exact bytes it received, answers inside Didit's 5-second timeout, and keeps every verification and transaction event for retry and replay long after Didit's two retries are spent.
Didit webhook limitations and pain points
Only two retries, minutes apart
The Problem: A few minutes of downtime and a verification result is gone.
Why It Happens: Didit retries a failed delivery twice, about 1 minute and then about 4 minutes later, and then drops it. It only retries 5xx, 404, timeouts and connection failures. Any other 4xx or a 3xx is not retried at all.
Workarounds:
- Keep the receiver minimal and return
2xxas soon as the event is stored. - Avoid returning
400,401or422for problems you could recover from, since those are never retried. - Replay missed deliveries from the destination's Deliveries tab, and reconcile against
GET /v3/session/{id}/decision/after an outage.
How Hookdeck Can Help: Hookdeck accepts and stores every delivery as it arrives, then retries against your service on a schedule you set, for as long as you need. A deploy or outage on your side no longer races Didit's five-minute retry window.
Three signatures, and the easy one is unsafe
The Problem: It isn't obvious which header to verify, and the one that works through any middleware doesn't protect the decision.
Why It Happens: X-Signature breaks if anything re-encodes the body, which is why Didit offers X-Signature-V2 and the older X-Signature-Simple. The Simple variant only covers four envelope fields, so a request with a rewritten decision still passes it.
Workarounds:
- Verify
X-Signaturewhen you control the raw body, orX-Signature-V2when you don't. - If you are stuck on
X-Signature-Simple, treat everything else in the body as untrusted and fetch the decision from Didit's API before acting.
How Hookdeck Can Help: Hookdeck's Didit source type verifies X-Signature over the raw bytes it received, so the body your service gets has already been authenticated in full.
A five-minute window that retries can't meet
The Problem: A receiver that enforces Didit's 300-second timestamp window rejects legitimate deliveries that were held up on the way.
Why It Happens: Didit refreshes X-Timestamp on its own retries, but any queue, replay tool or gateway that redelivers the request later sends the original header. The signature is still valid; only the age check fails.
Workarounds:
- Make the window optional, as in the code above, or size it for your redelivery path.
- Rely on
event_iddeduplication for replay protection instead of time alone.
How Hookdeck Can Help: Hookdeck does not enforce a timestamp window, so retries and replays through Hookdeck are verified on the signature alone.
Ordering is not guaranteed
The Problem: Events arrive in a different order from the one in which they happened.
Why It Happens: Didit states, for example, that Travel Rule events are queued before the related transaction.created but may not be delivered first. Retries can reorder anything.
Workarounds:
- Store state per session, entity or transaction and apply only changes that are newer than what you have, using
created_at. - When an event references a record you haven't seen, fetch it from the API.
How Hookdeck Can Help: Hookdeck keeps the full history of deliveries for each source, so you can inspect and replay a sequence when state looks wrong.
Best practices
Deduplicate on event_id
It is stable across retries and across destinations. Store it before processing. See our guide to webhook idempotency.
Route on webhook_type, then on status
status.updated covers every session status, so a handler usually switches on webhook_type and then on status. Handle Resubmitted, which has resubmit_info instead of a decision.
Separate sandbox and test traffic
Check environment for sandbox, and the X-Didit-Test-Webhook header for Try Webhook deliveries, before you change production state.
Acknowledge within five seconds
Didit times out after 5 seconds. Verify, store, return 2xx, and process asynchronously. See why to process webhooks asynchronously.
Use one destination per consumer
Each destination has its own secret, version and event list, so a rotated secret or a bad deploy on one consumer doesn't affect the others.
Allowlist Didit's IP if your edge blocks unknown clients
Didit sends from 18.203.201.92. If a WAF challenges unknown traffic, add an allow rule for that address on the webhook hostname.
Conclusion
Didit signs every delivery three ways with HMAC-SHA256 and the destination's secret_shared_key. Verify X-Signature over the raw body, or X-Signature-V2 if your stack re-encodes JSON, deduplicate on event_id, and answer within five seconds, because Didit retries only twice.
Hookdeck Event Gateway sits in front of your receiver, verifies X-Signature, and keeps every session, entity and transaction event until your service has processed it.
Get started with Hookdeck for free and handle Didit webhooks reliably in minutes.