Skip to main content

Overview

Webhooks are how Didit pushes real-time updates to your backend whenever a verification session, business session, vendor user, vendor business, or transaction changes state. Subscribing one or more destinations to the events you care about lets you react the moment a result is ready — usually within seconds of the verifying user finishing their flow. Webhooks are the recommended integration pattern. Polling GET /v3/session/{id}/decision/ is supported as a fallback, but it is slower, costs more requests, and skips events like data.updated, transaction.status.updated, and entity-level changes that are only emitted through webhooks. Treat webhook delivery as the source of truth and only poll on cold start or for reconciliation.
Cloudflare / WAF users. Didit delivers webhooks from the static IP 18.203.201.92 with User-Agent: DiditWebhook/2.0 +https://didit.me. If your edge blocks unknown clients, allow this IP in Security > WAF > Tools > IP Access Rules and choose Allow for the receiving hostname.

Event types

Use the exact value in a destination’s subscribed_events array. There is no wildcard — list every family you want to receive — and you can spread events across multiple destinations.
EventResourceWhen it fires
status.updatedKYC session or KYB business sessionA 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.updatedKYC session or KYB business sessionVerification data is edited after creation (manual reviewer corrections to KYC, POA, NFC, KYB registry, KYB documents, etc.).
user.status.updatedUser entityA consolidated user’s status changes between ACTIVE, FLAGGED, and BLOCKED (console action, rule action, blocklist). Includes previous_status.
user.data.updatedUser entityA consolidated user’s profile, counters, metadata, or approved-identifiers change. Includes changed_fields and a changes map of previous / current values.
business.status.updatedBusiness entityA consolidated business’s status changes between ACTIVE, FLAGGED, and BLOCKED (console action, rule action, blocklist). Includes previous_status.
business.data.updatedBusiness entityA consolidated business’s profile, registration data, counters, metadata, or aggregate verification fields change. Includes changed_fields and changes.
activity.createdActivity timelineReserved 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.createdTransaction MonitoringA transaction is created and its initial rules verdict is ready. Includes transaction_id, txn_id, status, score, severity, amount, currency, direction.
transaction.status.updatedTransaction MonitoringA 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, which goes to the per-request webhook_url you pass to POST /v3/kyb/search/, never to a destination.
The Business Console > API & Webhooks > Try Webhook menu sends fully-formed payloads for every one of these events (approved, declined, in-review, Unicode names, KYC/KYB, entity, activity, and transaction). Use it to validate your endpoint before going live.

Payload shape

Envelope

Every webhook shares the same envelope. Fields are always serialised with sort_keys=True and compact separators (, / :) so the same payload reproduces the same signature on both sides.
FieldTypeNotes
event_iduuidStable 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_typestringOne of the event types above.
timestampint (Unix seconds)When Didit dispatched the webhook. Used by signature verification. Refreshed on each retry.
created_atint (Unix seconds)When the underlying record was last updated.
application_iduuidThe application that owns the destination.
environment"live" | "sandbox"Whether the event came from a live or sandbox session/application.
statusstringPresent on every delivered event (including *.data.updated). Session events use the exact verification status labels (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:
FieldTypeNotes
session_iduuidThe user-verification or business-session UUID.
business_session_iduuidOnly on KYB. Equal to session_id.
session_kind"business"Only on KYB events; lets a generic handler branch.
workflow_iduuidWorkflow used to create the session, when set.
workflow_versionintWorkflow version.
vendor_datastringYour internal identifier, when supplied at session creation.
vendor_business_idstringOnly on KYB sessions, when supplied.
metadataobjectWhatever metadata you attached at session creation, with internal dedup keys stripped.
triggerstringOptional. 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).
decisionobjectPresent when status is Approved, Declined, In Review, or Abandoned. Mirrors the V3 session decision.
resubmit_info{ nodes_to_resubmit, reasons }Present when status is Resubmitted (no decision).

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

V3 plural arrays (important)

Inside decision, every per-feature result is a plural array so a single workflow can include several instances of the same feature (for example, multiple ID checks across documents):
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.

Entity event payloads

{
  "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:
{
  "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/ 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.
{
  "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 as destination webhooks (retries re-stamp timestamp and stay unsigned). See the KYB Registry API for the full candidate-list schema.

Signature verification

Every webhook is signed with HMAC-SHA256 using the destination’s secret_shared_key. Three signature headers are sent so you can pick the one that survives your stack:
HeaderWhat is signedMiddleware-safeWhen to use
X-Signature-V2Sorted, Unicode-preserved compact JSON (ensure_ascii=False)YesRecommended — works even if your framework re-encodes the body.
X-SignatureExact raw bytes of the request as Didit transmitted them (sort_keys, ensure_ascii=True)NoOnly when you can read the raw body before any parser touches it.
X-Signature-Simple"{timestamp}:{session_id}:{status}:{webhook_type}"YesFallback 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:
HeaderExampleMeaning
Content-Typeapplication/jsonBody is JSON.
User-AgentDiditWebhook/2.0 +https://didit.meIdentifies Didit’s webhook worker.
X-Timestamp1774970000Unix epoch seconds when the webhook was dispatched. Reject if abs(now - X-Timestamp) > 300.
X-Signature / X-Signature-V2 / X-Signature-Simplehex digestsHMAC-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.
  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.
1

Create the destination

Call POST /v3/webhook/destinations/ (or click Add destination in the console) with a label, public url, webhook_version ("v3"), and a subscribed_events array containing every event family this endpoint should receive. At least one event is required and no wildcard exists.
2

Store the secret_shared_key

The create response returns the destination’s secret_shared_key. Store it 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.
3

Implement the endpoint

Expose a public HTTPS POST route, read the raw body, verify the signature (preferring X-Signature-V2), check the timestamp window, then dispatch on webhook_type. Return 2xx as soon as you have queued the work — do heavy processing asynchronously.
4

Confirm receipts

Watch the Deliveries tab on the destination. Healthy webhooks return 200 within a second or two. 5xx/404 triggers Didit’s retry policy; persistent failures are dropped after the second retry.
5

Iterate with Try Webhook

Use the Try Webhook console scenarios to drive your endpoint through approved, declined, in-review, KYB, entity, activity, and transaction payloads before flipping production traffic on.
Didit webhook testing feature in the Business Console
You can create as many destinations as you need. A common pattern is one destination per consumer: e.g. KYC events → your auth service, KYB events → your compliance ops service, transactions → your fraud queue. Each destination has its own secret, version, and subscription list, so a rotated secret or a bad deploy on one consumer does not affect the others.

Code samples

To ensure the security of your webhook endpoint, verify the authenticity of incoming requests using the destination’s secret_shared_key.
Use X-Signature-V2. It is the variant most resilient to middleware re-encoding, and it fully authenticates the body (including decision).
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.
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

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)

{
  "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)

{
  "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)

{
  "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)

{
  "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)

{
  "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

{
  "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.
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);

Legacy: V2 webhook format

The fields below only apply when a destination is pinned to webhook_version: "v2". An even older "v1" pin also exists (a third, singular decision shape) for destinations created before V2 — contact support before relying on it. New integrations should use V3 (the default), which is documented above and uses plural arrays.
AspectV2V3
Signature headersX-Signature, X-Signature-V2, X-Signature-Simple (all three are sent regardless of version)Same
Decision field namesSingular objects (id_verification, liveness, face_match, …)Plural arrays (id_verifications, liveness_checks, face_matches, …)
Multiple feature instancesNot supportedSupported 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.
  • Webhook destinations API — list, create, update, delete destinations and inspect delivery logs.
  • Create webhook destination — request/response schema including the once-returned secret_shared_key.
  • Data models — canonical schema for every per-feature object embedded in decision.
  • Decision schema — full field list for the decision object embedded in session webhooks.
  • Verification statuses — exact status values and transitions referenced above.
  • Rate limiting — limits that apply when polling GET /v3/session/{id}/decision/ as a fallback.