Skip to main content
Webhooks allow your application to receive real-time notifications about changes to a verification session status on web app workflows. Here’s how you can configure and handle these notifications securely.
Note for Cloudflare Users: If you are using Cloudflare, you must whitelist our IP address to receive webhooks. To whitelist an IP address in Cloudflare, navigate to the “Security” section, then “WAF”, then “Tools” and “IP Access Rules”. Add the IP address 18.203.201.92, select “Allow”, and choose the website the rule applies to.

Configuring the Webhook Endpoint

1

Complete the Quick Start Guide

Refer to the Quick Start Guide to set up your team and application if you haven’t already.
2

Add Your Webhook URL and Copy the Webhook Secret Key

Go to API & Webhooks in the sidebar, enter your webhook URL, and copy the Webhook Secret Key — you’ll use it to validate incoming requests.
Test without running a full verification. From the API & Webhooks page, you can send test webhooks with different data and statuses directly to your endpoint. This lets you validate your integration is correctly handling all scenarios before going live.
Didit webhook testing feature in the Business Console

Signature Verification Methods

We provide three signature headers to accommodate different integration scenarios. Choose the method that best fits your infrastructure:
HeaderMethodBest ForMiddleware-Safe
X-SignatureRaw body HMACDirect access to raw request bytes❌ No
X-Signature-V2ASCII-escaped JSON HMACRecommended - Works with most middleware✅ Yes
X-Signature-SimpleField-based HMACFallback when other methods fail✅ Yes

Why Multiple Signatures?

Some web frameworks and middleware (e.g., Express.js body parsers, Django middleware, API gateways) automatically parse and re-stringify JSON payloads. When special characters like accents (José, Müller, 日本) are present, the re-encoded JSON may differ from the original:
  • Original: "name":"José"
  • Re-encoded: "name":"Jos\u00e9" (Unicode escaped)
This causes X-Signature verification to fail even though the data is identical. The X-Signature-V2 and X-Signature-Simple methods solve this problem.

Recommendation

  1. Try X-Signature-V2 first (recommended) - Works even if your middleware re-encodes the JSON
  2. Fall back to X-Signature-Simple if V2 fails - Verifies core fields only
  3. Use X-Signature only if you have direct access to the raw request bytes

Signature Details

X-Signature (Original)

  • Signs the exact raw JSON bytes sent in the request body
  • Uses Python’s default ASCII-escaped encoding (JoséJos\u00e9)
  • Requires access to raw request body before any parsing/re-encoding
  • May fail if middleware re-encodes Unicode characters differently
  • Signs unescaped Unicode JSON (José stays as José)
  • Parse the JSON, then re-encode with sort_keys=True and Unicode preserved
  • Matches what most middleware (Node.js, PHP, etc.) produces when re-stringifying
  • This is the recommended method as it survives most middleware re-encoding

X-Signature-Simple (Fallback)

  • Signs only core fields: "{timestamp}:{session_id}:{status}:{webhook_type}"
  • Completely independent of JSON encoding
  • Note: Does not verify integrity of decision or other fields

Webhook Event Types

We send webhooks for the following event types:
  • status.updated: Triggered whenever the verification status changes (e.g., from Not Started to In Progress, or from In Review to Approved). This includes the initial webhook sent when a session starts.
  • data.updated: Triggered when KYC or POA data is manually updated by a reviewer via the API. This allows you to stay in sync with data corrections or manual reviews.
Each webhook payload includes a webhook_type field indicating the event that triggered it. If the session status is Approved, Declined, In Review, or Abandoned, the webhook will also include a decision field containing detailed verification results. The session_id is always included. Thevendor_data, workflow_id and metadata fields are included only if they are included in the session.

Retry Policy

If delivering a webhook fails (i.e., your endpoint returns a 5xx status code or a 404), we will automatically retry the delivery. To provide you with a clear history of delivery attempts, each retry is logged as a new, separate event in your dashboard. Retries are performed with an exponential backoff schedule to avoid overwhelming your server. We will attempt to redeliver the webhook up to 2 times over a period of minutes:
  • 1st Retry: Approximately 1 minute after the initial failure.
  • 2nd Retry: Approximately 4 minutes after the first retry failure.
If all retry attempts fail, we will stop trying to send the webhook for that specific event.

Code Examples

To ensure the security of your webhook endpoint, verify the authenticity of incoming requests using your Webhook Secret Key.
Recommendation: Use X-Signature-V2 for robust verification that works regardless of how your middleware handles JSON encoding.
const express = require("express");
const crypto = require("crypto");

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

// Load the webhook secret from your environment
const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";

// Parse JSON body
app.use(express.json());

/**
 * Process floats to match server-side behavior.
 * Converts float values that are whole numbers to integers.
 */
function shortenFloats(data) {
  if (Array.isArray(data)) {
    return data.map(shortenFloats);
  } else if (data !== null && typeof data === "object") {
    return Object.fromEntries(
      Object.entries(data).map(([key, value]) => [key, shortenFloats(value)])
    );
  } else if (typeof data === "number" && !Number.isInteger(data) && data % 1 === 0) {
    return Math.trunc(data);
  }
  return data;
}

/**
 * Verify X-Signature-V2 (Recommended)
 * Works even if middleware re-encodes special characters.
 * Uses unescaped Unicode JSON - matches default JSON.stringify behavior.
 */
function verifySignatureV2(jsonBody, signatureHeader, timestampHeader, secretKey) {
  // Check timestamp freshness (within 5 minutes)
  const currentTime = Math.floor(Date.now() / 1000);
  const incomingTime = parseInt(timestampHeader, 10);
  if (Math.abs(currentTime - incomingTime) > 300) {
    return false;
  }

  // Process floats and create sorted JSON with unescaped Unicode
  const processedData = shortenFloats(jsonBody);
  
  // Sort keys recursively and stringify
  const sortKeys = (obj) => {
    if (Array.isArray(obj)) {
      return obj.map(sortKeys);
    } else if (obj !== null && typeof obj === "object") {
      return Object.keys(obj).sort().reduce((result, key) => {
        result[key] = sortKeys(obj[key]);
        return result;
      }, {});
    }
    return obj;
  };
  
  // JSON.stringify keeps Unicode as-is (e.g., "José" stays as "José")
  const canonicalJson = JSON.stringify(sortKeys(processedData));

  const hmac = crypto.createHmac("sha256", secretKey);
  const expectedSignature = hmac.update(canonicalJson, "utf8").digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature, "utf8"),
    Buffer.from(signatureHeader, "utf8")
  );
}

/**
 * Verify X-Signature-Simple (Fallback)
 * Independent of JSON encoding - verifies core fields only.
 */
function verifySignatureSimple(jsonBody, signatureHeader, timestampHeader, secretKey) {
  // Check timestamp freshness (within 5 minutes)
  const currentTime = Math.floor(Date.now() / 1000);
  const incomingTime = parseInt(timestampHeader, 10);
  if (Math.abs(currentTime - incomingTime) > 300) {
    return false;
  }

  // Build canonical string from core fields
  const canonicalString = [
    jsonBody.timestamp || "",
    jsonBody.session_id || "",
    jsonBody.status || "",
    jsonBody.webhook_type || "",
  ].join(":");

  const hmac = crypto.createHmac("sha256", secretKey);
  const expectedSignature = hmac.update(canonicalString).digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expectedSignature, "utf8"),
    Buffer.from(signatureHeader, "utf8")
  );
}

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

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

    // Try X-Signature-V2 first (recommended)
    if (signatureV2 && verifySignatureV2(jsonBody, signatureV2, timestamp, WEBHOOK_SECRET_KEY)) {
      console.log("Verified with X-Signature-V2");
    }
    // Fall back to X-Signature-Simple
    else if (signatureSimple && verifySignatureSimple(jsonBody, signatureSimple, timestamp, WEBHOOK_SECRET_KEY)) {
      console.log("Verified with X-Signature-Simple (fallback)");
    }
    else {
      return res.status(401).json({ message: "Invalid signature" });
    }

    // Signature verified - process the webhook
    const { session_id, status, vendor_data, workflow_id } = jsonBody;
    console.log(`Webhook received: session=${session_id}, status=${status}`);

    return res.json({ message: "Webhook event dispatched" });
  } catch (error) {
    console.error("Error in /webhook handler:", error);
    return res.status(401).json({ message: "Unauthorized" });
  }
});

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

Node.js / Express (Original: X-Signature with Raw Body)

If you have direct access to the raw request bytes and prefer the original method:
const express = require("express");
const bodyParser = require("body-parser");
const crypto = require("crypto");

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

// Load the webhook secret from your environment (or config)
const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";

// 1) Capture the raw body
app.use(
  bodyParser.json({
    verify: (req, res, buf, encoding) => {
      if (buf && buf.length) {
        // Store the raw body in the request object
        req.rawBody = buf.toString(encoding || "utf8");
      }
    },
  })
);

// 2) Define the webhook endpoint
app.post("/webhook", (req, res) => {
  try {
    const signature = req.get("X-Signature");
    const timestamp = req.get("X-Timestamp");

    // Ensure all required data is present
    if (!signature || !timestamp || !req.rawBody || !WEBHOOK_SECRET_KEY) {
      return res.status(401).json({ message: "Unauthorized" });
    }

    // 3) Validate the timestamp to ensure the request is fresh (within 5 minutes)
    const currentTime = Math.floor(Date.now() / 1000);
    const incomingTime = parseInt(timestamp, 10);
    if (Math.abs(currentTime - incomingTime) > 300) {
      return res.status(401).json({ message: "Request timestamp is stale." });
    }

    // 4) Generate an HMAC from the raw body using your shared secret
    const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET_KEY);
    const expectedSignature = hmac.update(req.rawBody).digest("hex");

    // 5) Compare using timingSafeEqual for security
    const expectedSignatureBuffer = Buffer.from(expectedSignature, "utf8");
    const providedSignatureBuffer = Buffer.from(signature, "utf8");

    if (
      expectedSignatureBuffer.length !== providedSignatureBuffer.length ||
      !crypto.timingSafeEqual(expectedSignatureBuffer, providedSignatureBuffer)
    ) {
      return res.status(401).json({
        message: `Invalid signature. Computed (${expectedSignature}), Provided (${signature})`,
      });
    }

    // 6) Parse the JSON and proceed (signature is valid at this point)
    const jsonBody = JSON.parse(req.rawBody);
    const { session_id, status, vendor_data, workflow_id } = jsonBody;

    // Example: upsert to database, handle "Approved" status, etc.
    // e.g. upsertVerification(session_id, status, vendor_data, workflow_id);

    return res.json({ message: "Webhook event dispatched" });
  } catch (error) {
    console.error("Error in /webhook handler:", error);
    return res.status(401).json({ message: "Unauthorized" });
  }
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
from fastapi import FastAPI, Request, HTTPException
from time import time
import json
import hmac
import hashlib
import os
from typing import Any

app = FastAPI()

def shorten_floats(data: Any) -> Any:
    """Process floats to match server-side behavior."""
    if isinstance(data, dict):
        return {key: shorten_floats(value) for key, value in data.items()}
    elif isinstance(data, list):
        return [shorten_floats(item) for item in data]
    elif isinstance(data, float):
        if data.is_integer():
            return int(data)
    return data

def verify_webhook_signature_v2(
    request_body_json: dict,
    signature_header: str,
    timestamp_header: str,
    secret_key: str
) -> bool:
    """
    Verify X-Signature-V2 (Recommended).
    Works even if middleware re-encodes special characters.
    """
    # Check timestamp freshness (within 5 minutes)
    timestamp = int(timestamp_header)
    current_time = int(time())
    if abs(current_time - timestamp) > 300:
        return False

    # Process floats and re-encode with unescaped Unicode
    # This matches what most middleware produces when re-stringifying
    processed_data = shorten_floats(request_body_json)
    encoded_data = json.dumps(
        processed_data,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False  # Keep Unicode as-is, like Node.js/PHP
    )

    # Calculate expected signature
    expected_signature = hmac.new(
        secret_key.encode("utf-8"),
        encoded_data.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature_header, expected_signature)

def verify_webhook_signature_simple(
    request_body_json: dict,
    signature_header: str,
    timestamp_header: str,
    secret_key: str
) -> bool:
    """
    Verify X-Signature-Simple (Fallback).
    Independent of JSON encoding - verifies core fields only.
    """
    # Check timestamp freshness (within 5 minutes)
    timestamp = int(timestamp_header)
    current_time = int(time())
    if abs(current_time - timestamp) > 300:
        return False

    # Build canonical string from core fields
    canonical_string = ":".join([
        str(request_body_json.get("timestamp", "")),
        str(request_body_json.get("session_id", "")),
        str(request_body_json.get("status", "")),
        str(request_body_json.get("webhook_type", "")),
    ])

    # Calculate expected signature
    expected_signature = hmac.new(
        secret_key.encode("utf-8"),
        canonical_string.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature_header, expected_signature)

@app.post("/webhook")
async def handle_webhook(request: Request):
    # Get the raw request body and parse JSON
    body = await request.body()
    json_body = json.loads(body.decode())

    # Get headers
    signature_v2 = request.headers.get("x-signature-v2")
    signature_simple = request.headers.get("x-signature-simple")
    timestamp = request.headers.get("x-timestamp")
    secret = os.getenv("WEBHOOK_SECRET_KEY")

    if not timestamp or not secret:
        raise HTTPException(status_code=401, detail="Missing required headers")

    # Try X-Signature-V2 first (recommended)
    verified = False
    if signature_v2 and verify_webhook_signature_v2(json_body, signature_v2, timestamp, secret):
        print("Verified with X-Signature-V2")
        verified = True
    # Fall back to X-Signature-Simple
    elif signature_simple and verify_webhook_signature_simple(json_body, signature_simple, timestamp, secret):
        print("Verified with X-Signature-Simple (fallback)")
        verified = True

    if not verified:
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Signature verified - process the webhook
    session_id = json_body.get("session_id")
    status = json_body.get("status")
    vendor_data = json_body.get("vendor_data")

    print(f"Webhook received: session={session_id}, status={status}")

    # Your business logic here...
    # e.g., update database, send notifications, etc.

    return {"message": "Webhook event dispatched"}

Python / FastAPI (Original: X-Signature with Raw Body)

If you have direct access to the raw request bytes:
from fastapi import FastAPI, Request, HTTPException
from time import time
import json
import hmac
import hashlib
import os

app = FastAPI()

def verify_webhook_signature(
    request_body: str,
    signature_header: str,
    timestamp_header: str,
    secret_key: str
) -> bool:
    """Verify incoming webhook signature using raw body."""
    # Check if timestamp is recent (within 5 minutes)
    timestamp = int(timestamp_header)
    current_time = int(time())
    if abs(current_time - timestamp) > 300:
        return False

    # Calculate expected signature
    expected_signature = hmac.new(
        secret_key.encode("utf-8"),
        request_body.encode("utf-8"),
        hashlib.sha256
    ).hexdigest()

    # Compare signatures using constant-time comparison
    return hmac.compare_digest(signature_header, expected_signature)

@app.post("/webhook")
async def handle_webhook(request: Request):
    # Get the raw request body as string
    body = await request.body()
    body_str = body.decode()

    # Parse JSON for later use
    json_body = json.loads(body_str)

    # Get headers
    signature = request.headers.get("x-signature")
    timestamp = request.headers.get("x-timestamp")
    secret = os.getenv("WEBHOOK_SECRET_KEY")

    if not all([signature, timestamp, secret]):
        raise HTTPException(status_code=401, detail="Unauthorized")

    if not verify_webhook_signature(body_str, signature, timestamp, secret):
        raise HTTPException(status_code=401, detail="Unauthorized")

    session_id = json_body.get("session_id")
    status = json_body.get("status")
    vendor_data = json_body.get("vendor_data")

    # Your business logic here...

    return {"message": "Webhook event dispatched"}
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class WebhookController extends Controller
{
    /**
     * Process floats to match server-side behavior.
     */
    private function shortenFloats($data)
    {
        if (is_array($data)) {
            $result = [];
            foreach ($data as $key => $value) {
                $result[$key] = $this->shortenFloats($value);
            }
            return $result;
        } elseif (is_float($data) && floor($data) == $data) {
            return (int) $data;
        }
        return $data;
    }

    /**
     * Sort array keys recursively.
     */
    private function sortKeysRecursive($data)
    {
        if (!is_array($data)) {
            return $data;
        }
        ksort($data);
        foreach ($data as $key => $value) {
            $data[$key] = $this->sortKeysRecursive($value);
        }
        return $data;
    }

    /**
     * Verify X-Signature-V2 (Recommended).
     * Works even if middleware re-encodes special characters.
     */
    private function verifySignatureV2(
        array $jsonBody,
        string $signatureHeader,
        string $timestampHeader,
        string $secretKey
    ): bool {
        // Check timestamp freshness (within 5 minutes)
        $timestamp = (int) $timestampHeader;
        $currentTime = time();
        if (abs($currentTime - $timestamp) > 300) {
            return false;
        }

        // Process floats and sort keys
        $processedData = $this->shortenFloats($jsonBody);
        $sortedData = $this->sortKeysRecursive($processedData);

        // Encode with ASCII escaping (JSON_UNESCAPED_SLASHES removes only slash escaping)
        $encodedData = json_encode($sortedData, JSON_UNESCAPED_SLASHES);

        // Calculate expected signature
        $expectedSignature = hash_hmac('sha256', $encodedData, $secretKey);

        return hash_equals($signatureHeader, $expectedSignature);
    }

    /**
     * Verify X-Signature-Simple (Fallback).
     * Independent of JSON encoding - verifies core fields only.
     */
    private function verifySignatureSimple(
        array $jsonBody,
        string $signatureHeader,
        string $timestampHeader,
        string $secretKey
    ): bool {
        // Check timestamp freshness (within 5 minutes)
        $timestamp = (int) $timestampHeader;
        $currentTime = time();
        if (abs($currentTime - $timestamp) > 300) {
            return false;
        }

        // Build canonical string from core fields
        $canonicalString = implode(':', [
            $jsonBody['timestamp'] ?? '',
            $jsonBody['session_id'] ?? '',
            $jsonBody['status'] ?? '',
            $jsonBody['webhook_type'] ?? '',
        ]);

        // Calculate expected signature
        $expectedSignature = hash_hmac('sha256', $canonicalString, $secretKey);

        return hash_equals($signatureHeader, $expectedSignature);
    }

    /**
     * Handle incoming webhook request.
     */
    public function handle(Request $request)
    {
        // Get headers
        $signatureV2 = $request->header('x-signature-v2');
        $signatureSimple = $request->header('x-signature-simple');
        $timestamp = $request->header('x-timestamp');
        $secret = env('WEBHOOK_SECRET_KEY');

        if (!$timestamp || !$secret) {
            return response()->json(['message' => 'Missing required headers'], 401);
        }

        // Parse JSON body
        $body = $request->json()->all();

        // Try X-Signature-V2 first (recommended)
        $verified = false;
        if ($signatureV2 && $this->verifySignatureV2($body, $signatureV2, $timestamp, $secret)) {
            \Log::info('Verified with X-Signature-V2');
            $verified = true;
        }
        // Fall back to X-Signature-Simple
        elseif ($signatureSimple && $this->verifySignatureSimple($body, $signatureSimple, $timestamp, $secret)) {
            \Log::info('Verified with X-Signature-Simple (fallback)');
            $verified = true;
        }

        if (!$verified) {
            return response()->json(['message' => 'Invalid signature'], 401);
        }

        // Signature verified - process the webhook
        $sessionId = $body['session_id'];
        $status = $body['status'];
        $vendorData = $body['vendor_data'] ?? null;

        // Your business logic here...
        DB::table('verifications')->updateOrInsert(
            ['id' => $sessionId],
            [
                'user_id' => $vendorData,
                'verification_status' => $status,
                'updated_at' => now(),
            ]
        );

        if ($status === 'Approved' && $vendorData) {
            DB::table('users')->updateOrInsert(
                ['id' => $vendorData],
                ['is_verified' => true, 'updated_at' => now()]
            );
        }

        return response()->json(['message' => 'Webhook event dispatched']);
    }
}

PHP / Laravel (Original: X-Signature with Raw Body)

If you have direct access to the raw request bytes:
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class WebhookController extends Controller
{
    /**
     * Verify webhook signature using raw body.
     */
    private function verifyWebhookSignature(
        string $requestBody,
        string $signatureHeader,
        string $timestampHeader,
        string $secretKey
    ): bool {
        // Check if timestamp is recent (within 5 minutes)
        $timestamp = (int) $timestampHeader;
        $currentTime = time();
        if (abs($currentTime - $timestamp) > 300) {
            return false;
        }

        // Calculate expected signature
        $expectedSignature = hash_hmac('sha256', $requestBody, $secretKey);

        // Compare signatures using constant-time comparison
        return hash_equals($signatureHeader, $expectedSignature);
    }

    /**
     * Handle incoming webhook request.
     */
    public function handle(Request $request)
    {
        // Get the raw request body
        $bodyContent = $request->getContent();

        // Get headers
        $signature = $request->header('x-signature');
        $timestamp = $request->header('x-timestamp');
        $secret = env('WEBHOOK_SECRET_KEY');

        if (!$signature || !$timestamp || !$secret) {
            return response()->json(['message' => 'Unauthorized'], 401);
        }

        if (!$this->verifyWebhookSignature($bodyContent, $signature, $timestamp, $secret)) {
            return response()->json(['message' => 'Unauthorized'], 401);
        }

        // Parse JSON for processing
        $body = json_decode($bodyContent, true);

        $sessionId = $body['session_id'];
        $status = $body['status'];
        $vendorData = $body['vendor_data'] ?? null;

        // Your business logic here...

        return response()->json(['message' => 'Webhook event dispatched']);
    }
}

Webhook Body Object Examples

The webhook payload varies depending on the status of the verification session. When the status is Approved or Declined, the body includes the decision field. For all other statuses, the decision field is not present.

Example without decision Field

{
  "session_id": "11111111-2222-3333-4444-555555555555",
  "status": "In Progress",
  "webhook_type": "status.updated",
  "created_at": 1627680000,
  "timestamp": 1627680000,
  "workflow_id": "11111111-2222-3333-4444-555555555555",
  "vendor_data": "11111111-1111-1111-1111-111111111111",
  "metadata": {
    "user_type": "premium",
    "account_id": "ABC123"
  }
}

Example with decision Field (V3 API Structure)

The V3 API uses plural arrays for each feature type, allowing multiple instances per feature. Each feature object includes a node_id for unique identification.
{
  "session_id": "11111111-2222-3333-4444-555555555555",
  "status": "Declined",
  "webhook_type": "status.updated",
  "created_at": 1627680000,
  "timestamp": 1627680000,
  "workflow_id": "11111111-2222-3333-4444-555555555555",
  "vendor_data": "11111111-1111-1111-1111-111111111111",
  "metadata": {
    "user_type": "premium",
    "account_id": "ABC123"
  },
  "decision": {
    "session_id": "11111111-2222-3333-4444-555555555555",
    "session_number": 43762,
    "session_url": "https://verify.didit.me/session/11111111-2222-3333-4444-555555555555",
    "status": "In Review",
    "workflow_id": "11111111-2222-3333-4444-555555555555",
    "features": ["ID_VERIFICATION", "NFC", "LIVENESS", "FACE_MATCH", "POA", "PHONE", "DATABASE_VALIDATION", "AML", "IP_ANALYSIS"],
    "vendor_data": "11111111-1111-1111-1111-111111111111",
    "metadata": {
      "user_type": "premium",
      "account_id": "ABC123"
    },
    "expected_details": {
      "first_name": "Carmen",
      "last_name": "Española Española"
    },
    "contact_details": {
      "email": "[email protected]",
      "email_lang": "es",
      "send_notification_emails": false
    },
    "callback": "https://verify.didit.me/",
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "document_type": "Identity Card",
        "document_number": "CAA000000",
        "personal_number": "99999999R",
        "portrait_image": "https://example.com/portrait.jpg",
        "front_image": "https://example.com/front.jpg",
        "front_video": "https://example.com/front.mp4",
        "back_image": "https://example.com/back.jpg",
        "back_video": "https://example.com/back.mp4",
        "full_front_image": "https://example.com/full_front.jpg",
        "full_back_image": "https://example.com/full_back.jpg",
        "front_image_camera_front": "https://example.com/front_camera_front.jpg",
        "back_image_camera_front": "https://example.com/back_camera_front.jpg",
        "date_of_birth": "1980-01-01",
        "age": 45,
        "expiration_date": "2031-06-02",
        "date_of_issue": "2021-06-02",
        "issuing_state": "ESP",
        "issuing_state_name": "Spain",
        "first_name": "Carmen",
        "last_name": "Española Española",
        "full_name": "Carmen Española Española",
        "gender": "F",
        "address": "Avda de Madrid 34, Madrid, Madrid",
        "formatted_address": "Avda de Madrid 34, Madrid, Madrid 28822, Spain",
        "place_of_birth": "Madrid",
        "marital_status": "Single",
        "nationality": "ESP",
        "extra_fields": {
          "dl_categories": [],
          "blood_group": null
        },
        "parsed_address": {
          "id": "7c6280a2-fb6a-4258-93d5-2ac987cbc6ba",
          "address_type": "Avda",
          "city": "Madrid",
          "label": "Spain ID Card Address",
          "region": "Madrid",
          "street_1": "Avda de Madrid 34",
          "street_2": null,
          "postal_code": "28822",
          "raw_results": {}
        },
        "front_image_camera_front_face_match_score": 95.5,
        "back_image_camera_front_face_match_score": 93.2,
        "extra_files": ["https://example.com/extra_id_verification.jpg"],
        "warnings": [
          {
            "risk": "QR_NOT_DETECTED",
            "additional_data": null,
            "log_type": "information",
            "short_description": "QR not detected",
            "long_description": "The system couldn't find or read the QR code on the document."
          }
        ],
        "matches": [
          {
            "session_id": "22222222-3333-4444-5555-666666666666",
            "session_number": 43761,
            "vendor_data": "user-previous",
            "verification_date": "2024-07-20T10:30:00Z",
            "user_details": {
              "name": "Carmen Española Española",
              "document_type": "Identity Card",
              "document_number": "CAA000000"
            },
            "status": "Approved",
            "is_blocklisted": false,
            "api_service": "VERIFICATION",
            "front_image_url": "https://example.com/match_front.jpg"
          }
        ]
      }
    ],
    "nfc_verifications": [
      {
        "node_id": "nfc_1",
        "status": "In Review",
        "portrait_image": "https://example.com/portrait.jpg",
        "signature_image": "https://example.com/signature.jpg",
        "chip_data": {
          "document_type": "ID",
          "issuing_country": "ESP",
          "document_number": "123456789",
          "expiration_date": "2030-01-01",
          "first_name": "John",
          "last_name": "Smith",
          "birth_date": "1990-05-15",
          "gender": "M",
          "nationality": "ESP",
          "address": "CALLE MAYOR 123 4B, MADRID, MADRID",
          "place_of_birth": "MADRID, MADRID"
        },
        "authenticity": {
          "sod_integrity": true,
          "dg_integrity": true
        },
        "certificate_summary": {
          "issuer": "Common Name: CSCA SPAIN",
          "subject": "Common Name: DS n-eID SPAIN 2",
          "serial_number": "118120836164494130086420187336801405660",
          "not_valid_after": "2031-02-18 10:21:11",
          "not_valid_before": "2020-11-18 10:21:11"
        },
        "warnings": [
          {
            "risk": "DATA_INCONSISTENT",
            "additional_data": null,
            "log_type": "warning",
            "short_description": "OCR and NFC mrz code extracted are not the same",
            "long_description": "The OCR data and NFC chip data don't match."
          }
        ]
      }
    ],
    "liveness_checks": [
      {
        "node_id": "liveness_1",
        "status": "Approved",
        "method": "ACTIVE_3D",
        "score": 89.92,
        "reference_image": "https://example.com/reference.jpg",
        "video_url": "https://example.com/video.mp4",
        "age_estimation": 24.3,
        "matches": [],
        "warnings": []
      }
    ],
    "face_matches": [
      {
        "node_id": "face_match_1",
        "status": "In Review",
        "score": 65.43,
        "source_image": "https://example.com/source-image.jpg",
        "target_image": "https://example.com/target-image.jpg",
        "warnings": [
          {
            "risk": "LOW_FACE_MATCH_SIMILARITY",
            "additional_data": null,
            "log_type": "warning",
            "short_description": "Low face match similarity",
            "long_description": "The facial features don't closely match the reference image."
          }
        ]
      }
    ],
    "phone_verifications": [
      {
        "node_id": "phone_1",
        "status": "Approved",
        "phone_number_prefix": "+34",
        "phone_number": "600600600",
        "full_number": "+34600600600",
        "country_code": "ES",
        "country_name": "Spain",
        "carrier": {
          "name": "Orange",
          "type": "mobile"
        },
        "is_disposable": false,
        "is_virtual": false,
        "verification_method": "sms",
        "verification_attempts": 1,
        "verified_at": "2024-07-28T06:47:35.654321Z",
        "lifecycle": [
          {
            "type": "PHONE_VERIFICATION_MESSAGE_SENT",
            "timestamp": "2025-08-24T09:12:39.580554+00:00",
            "details": { "status": "Success", "reason": null, "is_retry": false },
            "fee": 0.1
          },
          {
            "type": "VALID_CODE_ENTERED",
            "timestamp": "2025-08-24T09:12:39.662157+00:00",
            "details": { "code_tried": "123456", "status": "Approved" },
            "fee": 0
          },
          {
            "type": "PHONE_VERIFICATION_APPROVED",
            "timestamp": "2025-08-24T09:12:39.684292+00:00",
            "details": null,
            "fee": 0
          }
        ],
        "warnings": []
      }
    ],
    "email_verifications": [
      {
        "node_id": "email_1",
        "status": "Approved",
        "email": "[email protected]",
        "is_breached": true,
        "breaches": [
          {
            "name": "TAPAirPortugal",
            "domain": "flytap.com",
            "logo_path": "https://logos.haveibeenpwned.com/TAPAirPortugal.png",
            "breach_date": "2022-08-25",
            "description": "In August 2022, the Portuguese airline TAP Air Portugal was the target of a ransomware attack.",
            "is_verified": true,
            "data_classes": ["dates_of_birth", "email_addresses", "genders", "names", "nationalities", "phone_numbers", "physical_addresses"],
            "breach_emails_count": 6083479
          }
        ],
        "is_disposable": false,
        "is_undeliverable": false,
        "verification_attempts": 1,
        "verified_at": "2025-09-15T17:36:19.963451Z",
        "warnings": [
          {
            "feature": "EMAIL",
            "risk": "BREACHED_EMAIL_DETECTED",
            "additional_data": null,
            "log_type": "information",
            "short_description": "Breached email detected",
            "long_description": "This email address was found in one or more known data breaches."
          }
        ],
        "lifecycle": [
          {
            "type": "EMAIL_VERIFICATION_MESSAGE_SENT",
            "timestamp": "2025-09-15T17:36:19.792684+00:00",
            "details": { "status": "Success", "reason": null },
            "fee": 0.03
          },
          {
            "type": "VALID_CODE_ENTERED",
            "timestamp": "2025-09-15T17:36:19.963427+00:00",
            "details": { "code_tried": "123456", "status": "Approved" },
            "fee": 0
          }
        ]
      }
    ],
    "poa_verifications": [
      {
        "node_id": "poa_1",
        "status": "Declined",
        "document_file": "https://example.com/poa_document.pdf",
        "issuing_state": "AR",
        "document_type": "UTILITY_BILL",
        "document_language": "es",
        "document_metadata": {
          "file_size": 1000,
          "content_type": "application/pdf",
          "creation_date": "2021-01-01",
          "modified_date": "2021-01-01"
        },
        "issuer": "Aguas del Norte",
        "issue_date": "2021-02-01",
        "poa_address": "AVDA. MONSEÑOR TAVELLA N° 3396, SALTA",
        "poa_formatted_address": "Av. Monseñor Tavella 3396, A4400 Salta, Argentina",
        "poa_parsed_address": {
          "address_type": "Avenida",
          "street_1": "Avenida Monseñor Tavella 3396",
          "street_2": null,
          "city": "Salta",
          "region": "Salta",
          "country": "AR",
          "postal_code": "A4400",
          "document_location": { "latitude": -24.8208664, "longitude": -65.4131 }
        },
        "expected_details_address": null,
        "expected_details_formatted_address": null,
        "expected_details_parsed_address": {},
        "extra_fields": {
          "bank_account_number": "1234567890",
          "bank_iban": null,
          "bank_sort_code": null,
          "bank_routing_number": null,
          "bank_swift_bic": null,
          "bank_branch_name": null,
          "bank_branch_address": null,
          "document_phone_number": "+5491112345678",
          "additional_names": []
        },
        "extra_files": ["https://example.com/extra_poa.pdf"],
        "warnings": []
      }
    ],
    "aml_screenings": [
      {
        "node_id": "aml_1",
        "status": "In Review",
        "total_hits": 1,
        "entity_type": "person",
        "hits": [
          {
            "id": "cPtTGRKkyddAcAC4xgsLCm",
            "url": "https://www.wikidata.org/wiki/Q126539671",
            "match": false,
            "score": 1,
            "target": null,
            "caption": "David Sánchez Pérez-Castejón",
            "datasets": ["PEP"],
            "features": null,
            "rca_name": "",
            "last_seen": "2025-06-13T00:00:00",
            "risk_view": {
              "crimes": { "score": 0, "weightage": 20, "risk_level": "Low", "risk_scores": {} },
              "countries": { "score": 0, "weightage": 30, "risk_level": "Low", "risk_scores": {} },
              "categories": { "score": 100, "weightage": 50, "risk_level": "High", "risk_scores": { "PEP": 100 } },
              "custom_list": {}
            },
            "first_seen": "2025-01-18T00:00:00",
            "properties": {
              "name": ["David Sánchez Pérez-Castejón"],
              "alias": ["David Sánchez Pérez-Castejón", "David Azagra"],
              "notes": ["Spanish orchestra conductor"],
              "gender": ["male"],
              "lastName": ["Sánchez", "Pérez-Castejón"],
              "birthDate": ["1974"],
              "firstName": ["David"],
              "birthPlace": ["Madrid"]
            },
            "match_score": 98,
            "pep_matches": [
              {
                "aliases": ["David Sánchez Pérez-Castejón", "David Azagra"],
                "list_name": "Wikidata",
                "publisher": "Wikidata",
                "source_url": "https://www.wikidata.org/wiki/Q126539671",
                "description": "Wikidata is the structured data project of the Wikipedia community",
                "matched_name": "David Sánchez Pérez-Castejón",
                "pep_position": "",
                "date_of_birth": "1974",
                "place_of_birth": "Madrid"
              }
            ],
            "linked_entities": [{ "name": ["Pedro Sánchez"], "active": [], "status": [], "details": [], "relation": [] }],
            "warning_matches": [],
            "sanction_matches": [],
            "adverse_media_details": {
              "sentiment": "Moderately Negative",
              "entity_type": "person",
              "sentiment_score": -2,
              "adverse_keywords": { "fraud": 3, "corruption": 2 }
            },
            "adverse_media_matches": [
              {
                "country": "spain",
                "summary": "David Sánchez is implicated in legal violations.",
                "headline": "Example headline",
                "sentiment": "moderately negative",
                "thumbnail": "https://example.com/thumbnail.jpg",
                "source_url": "https://example.com/article",
                "author_name": null,
                "adverse_keywords": ["fraud", "corruption"],
                "sentiment_score": -2,
                "publication_date": "2025-06-10T20:46:38"
              }
            ],
            "additional_information": {}
          }
        ],
        "score": 80,
        "screened_data": {
          "full_name": "David Sánchez Pérez-Castejón",
          "nationality": "ES",
          "date_of_birth": "1974-01-01",
          "document_number": null
        },
        "warnings": [
          {
            "risk": "POSSIBLE_MATCH_FOUND",
            "additional_data": null,
            "log_type": "warning",
            "short_description": "Possible match found in AML screening",
            "long_description": "The AML screening process identified potential matches with watchlists or high-risk databases."
          }
        ]
      }
    ],
    "ip_analyses": [
      {
        "node_id": "ip_analysis_1",
        "status": "Approved",
        "device_brand": "Apple",
        "device_model": "iPhone",
        "browser_family": "Mobile Safari",
        "os_family": "iOS",
        "platform": "mobile",
        "device_fingerprint": "didit-fp-a1b2c3d4e5f6g7h8",
        "ip_country": "Spain",
        "ip_country_code": "ES",
        "ip_state": "Barcelona",
        "ip_city": "Barcelona",
        "latitude": 41.4022,
        "longitude": 2.1407,
        "ip_address": "83.50.226.71",
        "isp": null,
        "organization": null,
        "is_vpn_or_tor": false,
        "is_data_center": false,
        "time_zone": "Europe/Madrid",
        "time_zone_offset": "+0100",
        "locations_info": {
          "ip": { "location": { "latitude": 40.2206327, "longitude": 1.5770097 }, "distance_from_id_document": 23.4, "distance_from_poa_document": 12.3 },
          "id_document": { "location": { "latitude": 41.2706327, "longitude": 1.9770097 }, "distance_from_ip": 23.4, "distance_from_poa_document": 18.7 },
          "poa_document": { "location": { "latitude": 41.2706327, "longitude": 1.9770097 }, "distance_from_ip": 12.3, "distance_from_id_document": 18.7 }
        },
        "warnings": []
      }
    ],
    "database_validations": [
      {
        "node_id": "database_validation_1",
        "issuing_state": "BRA",
        "validation_type": "one_by_one",
        "screened_data": {
          "last_name": "De Lima",
          "first_name": "Fernando",
          "tax_number": "01234567890",
          "date_of_birth": "1980-01-01",
          "document_type": "ID",
          "expiration_date": "2030-01-01"
        },
        "validations": [
          {
            "validation": { "full_name": "full_match", "date_of_birth": "full_match", "identification_number": "full_match" },
            "source_data": { "identification_number": "01234567890", "first_name": "FERNANDO", "last_name": "DE LIMA", "date_of_birth": "1980-01-01", "gender": "M", "nationality": "BRA" }
          }
        ],
        "match_type": "full_match",
        "status": "Approved",
        "warnings": []
      }
    ],
    "questionnaire_responses": null,
    "reviews": [
      {
        "user": "[email protected]",
        "new_status": "Declined",
        "comment": "Possible match found in AML screening",
        "created_at": "2024-07-18T13:29:00.366811Z"
      }
    ],
    "created_at": "2024-07-24T08:54:25.443172Z"
  }
}

V3 API Field Name Changes

V2 Field (singular)V3 Field (plural array)
id_verificationid_verifications
nfcnfc_verifications
livenessliveness_checks
face_matchface_matches
poapoa_verifications
phonephone_verifications
emailemail_verifications
amlaml_screenings
ip_analysisip_analyses
database_validationdatabase_validations
For a complete list of possible properties and their values for the decision field, please refer to our API Reference.

V2 API Webhooks

The following section applies to the v2 API only. If you are using v3, use the code examples and signature methods above.

Differences from V3

FeatureV2V3
Signature headerX-Signature onlyX-Signature, X-Signature-V2, X-Signature-Simple
Signature methodRaw body HMAC-SHA256Multiple methods (see above)
Decision field namesSingular objects (id_verification, liveness, face_match, etc.)Plural arrays (id_verifications, liveness_checks, face_matches, etc.)
Multiple feature instancesNot supportedSupported via arrays with node_id

V2 Signature Verification

V2 webhooks only include the X-Signature header, which is an HMAC-SHA256 of the raw request body.
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";

// Capture the raw body
app.use(
  bodyParser.json({
    verify: (req, res, buf, encoding) => {
      if (buf && buf.length) {
        req.rawBody = buf.toString(encoding || "utf8");
      }
    },
  })
);

app.post("/webhook", (req, res) => {
  try {
    const signature = req.get("X-Signature");
    const timestamp = req.get("X-Timestamp");

    if (!signature || !timestamp || !req.rawBody || !WEBHOOK_SECRET_KEY) {
      return res.status(401).json({ message: "Unauthorized" });
    }

    // Validate timestamp freshness (within 5 minutes)
    const currentTime = Math.floor(Date.now() / 1000);
    const incomingTime = parseInt(timestamp, 10);
    if (Math.abs(currentTime - incomingTime) > 300) {
      return res.status(401).json({ message: "Request timestamp is stale." });
    }

    // Generate HMAC from raw body
    const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET_KEY);
    const expectedSignature = hmac.update(req.rawBody).digest("hex");

    // Compare using timingSafeEqual
    const expectedBuffer = Buffer.from(expectedSignature, "utf8");
    const providedBuffer = Buffer.from(signature, "utf8");

    if (
      expectedBuffer.length !== providedBuffer.length ||
      !crypto.timingSafeEqual(expectedBuffer, providedBuffer)
    ) {
      return res.status(401).json({ message: "Invalid signature" });
    }

    const jsonBody = JSON.parse(req.rawBody);
    const { session_id, status, vendor_data, workflow_id } = jsonBody;

    // Your business logic here...

    return res.json({ message: "Webhook event dispatched" });
  } catch (error) {
    console.error("Error in /webhook handler:", error);
    return res.status(401).json({ message: "Unauthorized" });
  }
});

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

V2 Webhook Body Example with Decision

In v2, the decision object uses singular field names (e.g., id_verification instead of id_verifications):
{
  "session_id": "11111111-2222-3333-4444-555555555555",
  "status": "Declined",
  "webhook_type": "status.updated",
  "created_at": 1627680000,
  "timestamp": 1627680000,
  "workflow_id": "11111111-2222-3333-4444-555555555555",
  "vendor_data": "11111111-1111-1111-1111-111111111111",
  "decision": {
    "session_id": "11111111-2222-3333-4444-555555555555",
    "session_number": 43762,
    "session_url": "https://verify.didit.me/session/11111111-2222-3333-4444-555555555555",
    "status": "In Review",
    "workflow_id": "11111111-2222-3333-4444-555555555555",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "AML"],
    "vendor_data": "11111111-1111-1111-1111-111111111111",
    "id_verification": {
      "status": "Approved",
      "document_type": "Identity Card",
      "document_number": "CAA000000",
      "first_name": "Carmen",
      "last_name": "Española Española",
      "date_of_birth": "1980-01-01",
      "issuing_state": "ESP",
      "warnings": []
    },
    "liveness": {
      "status": "Approved",
      "method": "ACTIVE_3D",
      "score": 89.92,
      "warnings": []
    },
    "face_match": {
      "status": "Approved",
      "score": 95.43,
      "warnings": []
    },
    "aml": {
      "status": "Approved",
      "total_hits": 0,
      "entity_type": "person",
      "hits": [],
      "score": 0,
      "warnings": []
    },
    "reviews": [],
    "created_at": "2024-07-24T08:54:25.443172Z"
  }
}