Gareth Wilson Gareth Wilson

Guide to Didit Webhooks: Features and Best Practices

Published


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-Signature signs the exact bytes Didit sent.
  • X-Signature-V2 signs 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-Simple signs only {timestamp}:{session_id}:{status}:{webhook_type}. Didit has deprecated it because it does not cover the decision or 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

FeatureDetails
ConfigurationBusiness Console (API & Webhooks) or POST /v3/webhook/destinations/
Subscription modelA required subscribed_events list per destination. No wildcard
Event fieldwebhook_type in the body
Signature headersX-Signature (raw body), X-Signature-V2 (canonical JSON), X-Signature-Simple (deprecated)
SchemeHMAC-SHA256, hex digest
Secretsecret_shared_key, one per destination, shown once at creation
TimestampX-Timestamp, Unix seconds, refreshed on every retry. Didit recommends rejecting anything older than 300 seconds
Payload versionsv3 (default), v2 and an older v1, pinned per destination
Timeout5 seconds
RetriesUp to 2, about 1 minute and then about 4 minutes later, on 5xx, 404, timeouts and connection failures
RedirectsNever followed
Source IP18.203.201.92, with User-Agent: DiditWebhook/2.0 +https://didit.me

Common events

AreaEventsFires when
Sessionsstatus.updated, data.updatedA KYC or KYB session changes status, or a reviewer edits its data
Usersuser.status.updated, user.data.updatedA consolidated user moves between ACTIVE, FLAGGED and BLOCKED, or their profile changes
Businessesbusiness.status.updated, business.data.updatedThe same for a consolidated business
Transactionstransaction.created, transaction.status.updatedA transaction is created with its initial verdict, or its status changes
Travel Ruletravel_rule.status.updatedA Travel Rule exchange changes status
Investigatorinvestigator.report.ready, investigator.decision.automated, investigator.decision.overridden, investigator.decision.contested, and eight moreDidit's Investigator writes a report, decides, is overridden, raises a question, and so on
Activityactivity.createdAccepted 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_search.resolved 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

  1. In the Business Console, go to API & Webhooks and add a destination, or call POST /v3/webhook/destinations/ with a label, your public HTTPS url, webhook_version: "v3" and a subscribed_events array.
  2. Store the secret_shared_key straight away, for example as DIDIT_WEBHOOK_SECRET. Didit only shows it at creation.
  3. 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: true header, so you can keep them out of production data.
  4. Watch the destination's Deliveries tab. Healthy deliveries return 200 within 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-Signature against 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 2xx as soon as the event is stored.
  • Avoid returning 400, 401 or 422 for 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-Signature when you control the raw body, or X-Signature-V2 when 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_id deduplication 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.