# 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-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

| 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_rule.status.updated` | 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:

```json
{
  "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.

```javascript
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:

```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](/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Didit webhooks reliably in minutes.