> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive real-time verification, entity, and transaction events from Didit. HMAC-SHA256 signing, fan-out destinations, and bounded retries.

export const VideoEmbed = ({src, title = "Video", type = "iframe"}) => <div className={type === "iframe" ? "didit-video-embed" : "didit-video-embed didit-video-native"}>
    {type === "iframe" ? <iframe src={src} title={title} style={{
  width: "100%",
  height: "100%",
  border: 0,
  borderRadius: "12px"
}} allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowFullScreen /> : <video controls autoPlay muted loop playsInline src={src} title={title} style={{
  width: "100%",
  height: "auto",
  display: "block",
  borderRadius: "12px"
}} />}
  </div>;

export const AgentPromptAccordion = ({prompt, title = "AI Agent Integration Prompt"}) => {
  const [copied, setCopied] = React.useState(false);
  const handleCopy = e => {
    e.stopPropagation();
    if (!prompt) return;
    navigator.clipboard.writeText(prompt.trim()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    });
  };
  const agents = ["Claude Code", "Codex", "Cursor", "Devin", "Windsurf", "GitHub Copilot"];
  return <div className="didit-agent-card">
      {}
      <div className="didit-agent-titlebar">
        <div className="didit-agent-dots" aria-hidden="true">
          <span className="didit-agent-dot didit-agent-dot-red"></span>
          <span className="didit-agent-dot didit-agent-dot-yellow"></span>
          <span className="didit-agent-dot didit-agent-dot-green"></span>
        </div>
        <span className="didit-agent-filename">{title}</span>
        <button type="button" className={`didit-agent-copy ${copied ? "didit-agent-copy-copied" : ""}`} onClick={handleCopy} title="Copy prompt to clipboard" aria-label={copied ? "Copied!" : "Copy prompt to clipboard"}>
          {copied ? <>
              <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
                <path d="M3 8.5l3.5 3.5L13 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <span>Copied</span>
            </> : <>
              <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
                <rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
                <path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" stroke="currentColor" strokeWidth="1.5" />
              </svg>
              <span>Copy</span>
            </>}
        </button>
      </div>

      {}
      <pre className="didit-agent-body"><code>{prompt.trim()}</code></pre>

      {}
      <div className="didit-agent-footer">
        <span className="didit-agent-footer-label">Paste into</span>
        <div className="didit-agent-chips">
          {agents.map(name => <span key={name} className="didit-agent-chip">{name}</span>)}
        </div>
      </div>
    </div>;
};

<AgentPromptAccordion
  title="Webhook Integration Prompt"
  prompt={`Add a Didit webhook endpoint to my application to receive real-time identity verification, entity, and transaction events.

## What to Build
A POST endpoint (e.g., /api/webhooks/didit) that:
1. Reads the raw request body (do NOT parse JSON before signature verification)
2. Verifies the HMAC-SHA256 signature (X-Signature-V2 recommended)
3. Validates the timestamp is fresh (within 5 minutes)
4. Parses the JSON body and processes the event by webhook_type
5. Returns 2xx ASAP and updates state idempotently

## Signature Headers
- X-Signature-V2: HMAC-SHA256 over the sorted, Unicode-preserved canonical JSON (recommended).
- X-Signature: HMAC-SHA256 over the exact raw bytes (works only if your stack does not re-encode the body).
- X-Signature-Simple: HMAC-SHA256 over "{timestamp}:{session_id}:{status}:{webhook_type}" (fallback; does not authenticate the decision body).
- X-Timestamp: Unix epoch seconds when Didit dispatched the webhook.

Verification:
1. abs(now - X-Timestamp) <= 300 seconds
2. Recompute the signature using your destination secret
3. Compare with constant-time comparison

## Webhook Event Types (use exact values in destination.subscribed_events)
- "status.updated": session or business session status changed
- "data.updated": session or business-session verification data was edited
- "user.status.updated": User entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
- "user.data.updated": User entity profile or aggregate data changed
- "business.status.updated": Business entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
- "business.data.updated": Business entity profile or aggregate data changed
- "activity.created": reserved for the activity timeline (currently only sent by Try Webhook tests)
- "transaction.created": transaction created and initial verdict ready
- "transaction.status.updated": transaction status changed

There is no wildcard; include every event family you want to receive. You can create multiple destinations to fan out to different URLs.

## Webhook Envelope (session events)
{
"event_id": "uuid",
"session_id": "uuid",
"status": "Approved" | "Declined" | "In Review" | "In Progress" | "Not Started" | "Abandoned" | "Expired" | "Kyc Expired" | "Resubmitted" | "Awaiting User",
"webhook_type": "status.updated",
"created_at": 1627680000,
"timestamp": 1627680000,
"application_id": "uuid",
"environment": "live" | "sandbox",
"workflow_id": "uuid",
"workflow_version": 1,
"vendor_data": "your-internal-user-id",
"metadata": { "user_type": "premium" },
"decision": { ... }  // only when Approved / Declined / In Review / Abandoned
}

Status values are exact, case-sensitive strings — note "Kyc Expired" uses a single capital K.

KYB / business-session payloads additionally include "business_session_id" and "session_kind": "business".

## Decision Object (V3)
The decision mirrors the GET /v3/session/decision/ response. Per-feature data is in plural arrays so a workflow can include multiple instances of the same feature:
- id_verifications[], nfc_verifications[], liveness_checks[], face_matches[]
- aml_screenings[], poa_verifications[], phone_verifications[], email_verifications[]
- ip_analyses[], database_validations[], questionnaire_responses
- reviews[]

Each item carries a node_id so you can correlate it with the workflow graph.

## Database Update Logic
switch (status):
"Approved"     -> user.verified = true; store decision data
"Declined"     -> user.verification_status = "declined"; log decision.*.warnings
"In Review"    -> user.verification_status = "pending_review"
"In Progress"  -> user.verification_status = "in_progress"
"Resubmitted"  -> reopen the listed feature nodes per resubmit_info
"Abandoned"    -> optionally trigger reminder
"Awaiting User" -> KYB parent session waiting on party verifications; no action needed
"Expired" / "Kyc Expired" -> mark session expired; optionally create a new one

## Retry Policy
On 5xx, 404, timeout, or connection failure Didit retries up to 2 times:
- 1st retry: ~1 minute after the initial failure
- 2nd retry: ~4 minutes after the 1st retry
After that the delivery is dropped. Each delivery attempt is logged separately in the console's Deliveries tab.

## Error Handling
- Return 2xx as fast as possible; do heavy work asynchronously.
- Log the raw body for debugging signature failures.
- Treat duplicate webhooks idempotently (key on event_id, or session_id + status + webhook_type).
- HTTPS endpoints only. Webhook receivers behind Cloudflare must whitelist 18.203.201.92.

## Environment Variable
DIDIT_WEBHOOK_SECRET -- from the Business Console (per-destination secret_shared_key).

## Testing
Send test webhooks from the Business Console (API & Webhooks > Try Webhook) before going live. The console covers approved, declined, in-review, KYB, entity, activity, and transaction scenarios.

## Docs
- Webhooks guide: https://docs.didit.me/integration/webhooks
- Webhook destinations API: https://docs.didit.me/management-api/webhook/list-destinations
- Verification statuses: https://docs.didit.me/integration/verification-statuses
- Decision schema: https://docs.didit.me/sessions-api/retrieve-session

## Important
- ALWAYS verify the signature before processing any webhook
- Use constant-time comparison to prevent timing attacks
- Store and HMAC the raw JSON string -- never re-stringify after parsing
- Return 2xx quickly; do heavy processing asynchronously if needed`}
/>

## 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/`](/sessions-api/retrieve-session) 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.

<VideoEmbed src="https://www.youtube.com/embed/h0i9Q0-izcw?start=3208&rel=0&playsinline=1" title="API Keys, Webhook Testing & Notifications" />

<Note>
  **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.
</Note>

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

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

These nine values 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](#kyb-registry-search-callback), which goes to the per-request `webhook_url` you pass to [`POST /v3/kyb/search/`](/standalone-apis/kyb-registry), never to a destination.

<Tip>
  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.
</Tip>

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

| 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](#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](/integration/verification-statuses) (e.g. `"Approved"`, `"Kyc Expired"`); entity events use `ACTIVE` / `FLAGGED` / `BLOCKED`; transaction events use `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |

For **session** events (`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](/sessions-api/retrieve-session).                                                                |
| `resubmit_info`       | `{ nodes_to_resubmit, reasons }` | Present when `status` is `Resubmitted` (no `decision`).                                                                                                                                                         |

### Session envelope example

```json theme={null}
{
  "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",
  "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" }
}
```

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

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

```text theme={null}
id_verifications[]        nfc_verifications[]
liveness_checks[]         face_matches[]
phone_verifications[]     email_verifications[]
poa_verifications[]       aml_screenings[]
ip_analyses[]             database_validations[]
questionnaire_responses   reviews[]
```

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

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](/core-technology/document-ai/overview).

### Entity event payloads

```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```

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:

```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND"
}
```

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 webhooks are only delivered for **live** applications — sandbox transactions never fan out.

### KYB registry search callback

The asynchronous [`POST /v3/kyb/search/`](/standalone-apis/kyb-registry) 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`.

```json theme={null}
{
  "event_id": "uuid",
  "event_type": "kyb.registry_search.resolved",
  "request_id": "uuid",
  "vendor_data": "business-123",
  "metadata": { "source": "onboarding" },
  "search_status": "resolved",
  "search_resolved": true,
  "kyb_registry": { "companies": ["..."], "pagination": { "...": "..." } },
  "timestamp": 1774970000,
  "created_at": 1774970000
}
```

The callback follows the same [retry policy](#retry-policy) as destination webhooks (retries re-stamp `timestamp` and stay unsigned). See the [KYB Registry API](/standalone-apis/kyb-registry) 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:

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

Headers Didit always sends:

| 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

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. If both fail, fall back to **`X-Signature-Simple`** against `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")` — and treat any `decision` data as untrusted unless you can re-fetch it from the API.

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.

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.
* `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.

## Setting up a destination

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

<Steps>
  <Step title="Create the destination">
    Call [`POST /v3/webhook/destinations/`](/management-api/webhook/create-destination) (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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Iterate with Try Webhook">
    Use the [**Try Webhook**](#testing) console scenarios to drive your endpoint through approved, declined, in-review, KYB, entity, activity, and transaction payloads before flipping production traffic on.
  </Step>
</Steps>

<Frame caption="Send test webhooks directly from the Business Console to validate your endpoint before going live.">
  <img src="https://mintcdn.com/didit-0f962782/z6T2GHM4Zh-iSj-K/images/test-webhook.png?fit=max&auto=format&n=z6T2GHM4Zh-iSj-K&q=85&s=2787ef3ed7a8016798f951e5ae898e4e" alt="Didit webhook testing feature in the Business Console" width="2710" height="1758" data-path="images/test-webhook.png" />
</Frame>

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

<Warning>
  **Use `X-Signature-V2`.** It is the variant most resilient to middleware re-encoding, and it fully authenticates the body (including `decision`).
</Warning>

### Node.js / Express (recommended: X-Signature-V2)

```javascript theme={null}
const express = require("express");
const crypto = require("crypto");

const app = express();
const PORT = process.env.PORT || 1337;

// Per-destination secret from POST /v3/webhook/destinations/
const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";

app.use(express.json());

/**
 * Match Didit's float normalisation: whole-valued floats are serialised as ints.
 */
function shortenFloats(data) {
  if (Array.isArray(data)) return data.map(shortenFloats);
  if (data !== null && typeof data === "object") {
    return Object.fromEntries(
      Object.entries(data).map(([key, value]) => [key, shortenFloats(value)])
    );
  }
  if (typeof data === "number" && !Number.isInteger(data) && data % 1 === 0) {
    return Math.trunc(data);
  }
  return data;
}

/** Sort object keys recursively before re-stringifying. */
function sortKeys(obj) {
  if (Array.isArray(obj)) return obj.map(sortKeys);
  if (obj !== null && typeof obj === "object") {
    return Object.keys(obj).sort().reduce((acc, key) => {
      acc[key] = sortKeys(obj[key]);
      return acc;
    }, {});
  }
  return obj;
}

function verifySignatureV2(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  // Reproduce Didit's canonical JSON: sorted keys, compact, Unicode preserved.
  const canonical = JSON.stringify(sortKeys(shortenFloats(jsonBody)));
  const expected = crypto.createHmac("sha256", secret).update(canonical, "utf8").digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifySignatureSimple(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  const canonical = [
    jsonBody.timestamp ?? "",
    jsonBody.session_id ?? "",
    jsonBody.status ?? "",
    jsonBody.webhook_type ?? "",
  ].join(":");
  const expected = crypto.createHmac("sha256", secret).update(canonical).digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", (req, res) => {
  const signatureV2 = req.get("X-Signature-V2");
  const signatureSimple = req.get("X-Signature-Simple");
  const timestamp = req.get("X-Timestamp");
  const body = req.body;

  if (!timestamp || !WEBHOOK_SECRET_KEY) {
    return res.status(401).json({ message: "Missing required headers" });
  }

  let verified = false;
  if (signatureV2 && verifySignatureV2(body, signatureV2, timestamp, WEBHOOK_SECRET_KEY)) {
    verified = true;
  } else if (signatureSimple && verifySignatureSimple(body, signatureSimple, timestamp, WEBHOOK_SECRET_KEY)) {
    verified = true; // body integrity is NOT verified by Simple; re-fetch decision if needed.
  }
  if (!verified) return res.status(401).json({ message: "Invalid signature" });

  // Dispatch by event type. Return 2xx fast; do heavy work asynchronously.
  switch (body.webhook_type) {
    case "status.updated":
    case "data.updated":
      // session or business session
      break;
    case "user.status.updated":
    case "user.data.updated":
    case "business.status.updated":
    case "business.data.updated":
      // consolidated entity event
      break;
    case "activity.created":
      // audit timeline event
      break;
    case "transaction.created":
    case "transaction.status.updated":
      // transaction monitoring event
      break;
  }
  return res.status(200).json({ ok: true });
});

app.listen(PORT, () => console.log(`Server on http://localhost:${PORT}`));
```

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.

### Python / FastAPI (recommended: X-Signature-V2)

```python theme={null}
from fastapi import FastAPI, Request, HTTPException
from time import time
import json
import hmac
import hashlib
import os

app = FastAPI()


def shorten_floats(data):
    """Match Didit: whole-valued floats serialise as ints."""
    if isinstance(data, dict):
        return {k: shorten_floats(v) for k, v in data.items()}
    if isinstance(data, list):
        return [shorten_floats(x) for x in data]
    if isinstance(data, float) and data.is_integer():
        return int(data)
    return data


def verify_signature_v2(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = json.dumps(
        shorten_floats(body_json),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,   # unescaped Unicode -- matches Didit's V2 canonical form
    )
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


def verify_signature_simple(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = ":".join([
        str(body_json.get("timestamp", "")),
        str(body_json.get("session_id", "")),
        str(body_json.get("status", "")),
        str(body_json.get("webhook_type", "")),
    ])
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


@app.post("/webhook")
async def handle_webhook(request: Request):
    body = json.loads((await request.body()).decode("utf-8"))
    secret = os.environ["WEBHOOK_SECRET_KEY"]

    sig_v2 = request.headers.get("x-signature-v2")
    sig_simple = request.headers.get("x-signature-simple")
    ts = request.headers.get("x-timestamp")
    if not ts:
        raise HTTPException(status_code=401, detail="Missing X-Timestamp")

    if sig_v2 and verify_signature_v2(body, sig_v2, ts, secret):
        pass
    elif sig_simple and verify_signature_simple(body, sig_simple, ts, secret):
        # Body integrity is NOT verified by Simple; re-fetch decision via API if needed.
        pass
    else:
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Dispatch on body["webhook_type"]; return 2xx fast.
    return {"ok": True}
```

### PHP / Laravel (recommended: X-Signature-V2)

```php theme={null}
<?php

use Illuminate\Http\Request;

class WebhookController extends Controller
{
    private function shortenFloats($data)
    {
        if (is_array($data)) {
            return array_map([$this, 'shortenFloats'], $data);
        }
        if (is_float($data) && floor($data) == $data) {
            return (int) $data;
        }
        return $data;
    }

    private function sortKeysRecursive($data)
    {
        if (!is_array($data)) return $data;
        ksort($data);
        foreach ($data as $k => $v) $data[$k] = $this->sortKeysRecursive($v);
        return $data;
    }

    private function verifySignatureV2(array $body, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        $canonical = json_encode(
            $this->sortKeysRecursive($this->shortenFloats($body)),
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );
        $expected = hash_hmac('sha256', $canonical, $secret);
        return hash_equals($sig, $expected);
    }

    private function verifySignatureSimple(array $body, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        $canonical = implode(':', [
            $body['timestamp'] ?? '',
            $body['session_id'] ?? '',
            $body['status'] ?? '',
            $body['webhook_type'] ?? '',
        ]);
        $expected = hash_hmac('sha256', $canonical, $secret);
        return hash_equals($sig, $expected);
    }

    public function handle(Request $request)
    {
        $secret = env('WEBHOOK_SECRET_KEY');
        $ts = $request->header('x-timestamp');
        if (!$ts || !$secret) {
            return response()->json(['message' => 'Missing required headers'], 401);
        }

        $body = $request->json()->all();
        $sigV2 = $request->header('x-signature-v2');
        $sigSimple = $request->header('x-signature-simple');

        $verified = ($sigV2 && $this->verifySignatureV2($body, $sigV2, $ts, $secret))
            || ($sigSimple && $this->verifySignatureSimple($body, $sigSimple, $ts, $secret));
        if (!$verified) {
            return response()->json(['message' => 'Invalid signature'], 401);
        }

        // Dispatch on $body['webhook_type']; return 2xx fast.
        return response()->json(['ok' => true]);
    }
}
```

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

```json theme={null}
{
  "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",
  "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": {
    "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
    "session_number": 43762,
    "status": "Approved",
    "workflow_id": "66666666-7777-8888-9999-000000000000",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "AML"],
    "vendor_data": "user_42",
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "document_type": "Identity Card",
        "document_number": "SAMPLE-DOC-12345",
        "first_name": "Jane",
        "last_name": "Doe",
        "date_of_birth": "1990-01-01",
        "issuing_state": "ESP",
        "warnings": []
      }
    ],
    "liveness_checks": [
      { "node_id": "liveness_1", "status": "Approved", "method": "ACTIVE_3D", "score": 95.4, "warnings": [] }
    ],
    "face_matches": [
      { "node_id": "face_match_1", "status": "Approved", "score": 96.1, "warnings": [] }
    ],
    "aml_screenings": [
      { "node_id": "aml_1", "status": "Approved", "total_hits": 0, "entity_type": "person", "hits": [], "warnings": [] }
    ],
    "reviews": [],
    "created_at": "2026-05-17T08:54:25.443172Z"
  }
}
```

### Declined session with warnings (`status.updated`)

```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-555555555555",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
  "status": "Declined",
  "vendor_data": "user_99",
  "decision": {
    "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
    "status": "Declined",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Declined",
        "document_type": "Passport",
        "expiration_date": "2024-01-01",
        "warnings": [
          {
            "feature": "ID_VERIFICATION",
            "risk": "DOCUMENT_EXPIRED",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Document expired",
            "long_description": "The document's expiration date has passed, rendering it no longer valid for use.",
            "node_id": "id_verification_1"
          }
        ]
      }
    ],
    "liveness_checks": [{ "node_id": "liveness_1", "status": "Approved", "score": 92.0, "warnings": [] }],
    "face_matches": [
      {
        "node_id": "face_match_1",
        "status": "Declined",
        "score": 32.0,
        "warnings": [
          {
            "feature": "FACEMATCH",
            "risk": "LOW_FACE_MATCH_SIMILARITY",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Low face match similarity",
            "long_description": "The facial features of the provided image don't closely match the reference image, suggesting a potential identity mismatch.",
            "node_id": "face_match_1"
          }
        ]
      }
    ],
    "reviews": []
  }
}
```

### Business session approved (`status.updated`, KYB)

```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "session_id": "kyb-uuid",
  "business_session_id": "kyb-uuid",
  "session_kind": "business",
  "status": "Approved",
  "vendor_data": "biz_7",
  "vendor_business_id": "acme-inc",
  "decision": {
    "session_id": "kyb-uuid",
    "session_kind": "business",
    "status": "Approved",
    "features": ["REGISTRY_CHECK", "AML", "DOCUMENT_VERIFICATION"],
    "registry_checks": [{ "node_id": "registry_1", "status": "Approved", "company_name": "Acme Corp", "...": "..." }],
    "aml_screenings": [{ "node_id": "aml_1", "status": "Approved", "entity_type": "company", "total_hits": 0, "hits": [] }],
    "document_verifications": [{ "node_id": "doc_1", "status": "Approved", "document_type": "certificate_of_incorporation" }],
    "key_people_checks": [],
    "reviews": []
  }
}
```

### Transaction created (`transaction.created`)

```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND"
}
```

### User entity status changed (`user.status.updated`)

```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```

### Resubmitted session

```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "session_id": "uuid",
  "status": "Resubmitted",
  "vendor_data": "user_42",
  "resubmit_info": {
    "nodes_to_resubmit": [
      { "node_id": "feature_poa", "feature": "PROOF_OF_ADDRESS" }
    ],
    "reasons": { "feature_poa": "Poor document quality" }
  }
}
```

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

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    const express = require("express");
    const bodyParser = require("body-parser");
    const crypto = require("crypto");

    const app = express();
    const PORT = process.env.PORT || 1337;
    const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";

    app.use(bodyParser.json({
      verify: (req, _res, buf, encoding) => {
        if (buf && buf.length) req.rawBody = buf.toString(encoding || "utf8");
      },
    }));

    app.post("/webhook", (req, res) => {
      const signature = req.get("X-Signature");
      const timestamp = req.get("X-Timestamp");
      if (!signature || !timestamp || !req.rawBody) return res.status(401).end();

      if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > 300) {
        return res.status(401).json({ message: "Stale timestamp" });
      }

      const expected = crypto.createHmac("sha256", WEBHOOK_SECRET_KEY).update(req.rawBody).digest("hex");
      const a = Buffer.from(expected, "utf8");
      const b = Buffer.from(signature, "utf8");
      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.status(401).json({ message: "Invalid signature" });
      }

      const event = JSON.parse(req.rawBody);
      // Handle event.webhook_type ...
      return res.json({ ok: true });
    });

    app.listen(PORT);
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    from fastapi import FastAPI, Request, HTTPException
    from time import time
    import hmac
    import hashlib
    import os

    app = FastAPI()


    def verify_raw(body: str, signature: str, timestamp: str, secret: str) -> bool:
        if abs(int(time()) - int(timestamp)) > 300:
            return False
        expected = hmac.new(secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest()
        return hmac.compare_digest(signature, expected)


    @app.post("/webhook")
    async def handle(request: Request):
        body = (await request.body()).decode("utf-8")
        sig = request.headers.get("x-signature")
        ts = request.headers.get("x-timestamp")
        secret = os.environ["WEBHOOK_SECRET_KEY"]

        if not sig or not ts or not verify_raw(body, sig, ts, secret):
            raise HTTPException(status_code=401, detail="Unauthorized")
        return {"ok": True}
    ```
  </Tab>
</Tabs>

## Legacy: V2 webhook format

<Warning>
  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.
</Warning>

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

If a downstream consumer still needs V2 shapes, create a separate destination pinned to `webhook_version: "v2"` and keep your new consumers on V3.

## Related

* [Webhook destinations API](/management-api/webhook/list-destinations) — list, create, update, delete destinations and inspect delivery logs.
* [Create webhook destination](/management-api/webhook/create-destination) — request/response schema including the once-returned `secret_shared_key`.
* [Data models](/reference/data-models) — canonical schema for every per-feature object embedded in `decision`.
* [Decision schema](/sessions-api/retrieve-session) — full field list for the `decision` object embedded in session webhooks.
* [Verification statuses](/integration/verification-statuses) — exact `status` values and transitions referenced above.
* [Rate limiting](/integration/rate-limiting) — limits that apply when polling `GET /v3/session/{id}/decision/` as a fallback.
