🎉 Unlimited Free KYC - Forever!!

Identity Verification
Webhooks

Webhooks

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.

Configuring the Webhook Endpoint

Follow Steps 1 and 2 from the Quick Start Guide

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

Add Your Webhook URL and Copy the Webhook Secret Key

  • Go to your verification settings.
  • Enter your webhook URL.
  • Copy the Webhook Secret Key, which you'll use to validate incoming requests.
Webhook Configuration

Webhook Types

We send webhooks in the following scenarios:

  • Session Starts – When a new verification session begins, we immediately send its initial status with a status of Not Started.
  • Status Changes – Whenever the verification status is updated (e.g., Approved, Declined, In Review, Abandoned).

If the status is one of Approved, Declined, In Review, or Abandoned, the webhook includes a decision field with detailed verification information. The workflow_id, vendor_data, metadata, and session_id fields are also included.

Code Examples

To ensure the security of your webhook endpoint, verify the authenticity of incoming requests using your Webhook Secret Key. The most important step is to always sign and verify the exact raw JSON—any re-stringification can alter the payload and invalidate the signature.

Always store and HMAC the raw JSON string (rather than re-stringifying after parsing). Differences in whitespace, float formatting, or key ordering will break the signature verification.


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 Dict, Any
from prisma import Prisma  # You'll need to set up Prisma client for Python
 
app = FastAPI()
prisma = Prisma()
 
def verify_webhook_signature(request_body: str, signature_header: str, timestamp_header: str, secret_key: str) -> bool:
    """
    Verify incoming webhook signature
    """
    # Check if timestamp is recent (within 5 minutes)
    timestamp = int(timestamp_header)
    current_time = int(time())
    if abs(current_time - timestamp) > 300:  # 5 minutes
        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 = body.get("session_id")
    status = body.get("status")
    vendor_data = body.get("vendor_data")
 
    # Connect to database
    await prisma.connect()
 
    try:
        # Update or create verification record
        upsert_result = await prisma.verification.upsert(
            where={
                "id": session_id
            },
            data={
                "update": {
                    "verificationStatus": status
                },
                "create": {
                    "userId": vendor_data,
                    "id": session_id,
                    "verificationStatus": status
                    # Add other required fields for creation
                }
            }
        )
 
        # Handle approved status
        if status == "Approved":
            user_id = upsert_result.userId
 
            await prisma.user.upsert(
                where={
                    "id": user_id
                },
                data={
                    "update": {
                        "isVerified": True
                    },
                    "create": {
                        "id": user_id,
                        "isVerified": True
                        # Add other required fields for user creation
                    }
                }
            )
 
        return {"message": "Webhook event dispatched"}
 
    finally:
        await prisma.disconnect()
<?php
 
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
 
class WebhookController extends Controller
{
    /**
     * 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'];
 
        // Update or create verification record
        $verification = DB::table('verifications')->updateOrInsert(
            ['id' => $sessionId],
            [
                'user_id' => $vendorData,
                'verification_status' => $status,
                'updated_at' => now(),
            ]
        );
 
        // Handle approved status
        if ($status === 'Approved') {
            DB::table('users')->updateOrInsert(
                ['id' => $vendorData],
                [
                    'is_verified' => true,
                    'updated_at' => now(),
                ]
            );
        }
 
    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);
    }
}

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

{
  "session_id": "11111111-2222-3333-4444-555555555555",
  "status": "Declined", // status of the verification session
  "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", "AML", "IP_ANALYSIS"],
    "vendor_data": "11111111-1111-1111-1111-111111111111",
    "metadata": {
      "user_type": "premium",
      "account_id": "ABC123"
    },
    "callback": "https://verify.didit.me/",
    // optional field if ID_VERIFICATION feature is enabled
    "id_verification": {
      "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",
      "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",
      "parsed_address": {
        "id": "7c6280a2-fb6a-4258-93d5-2ac987cbc6ba",
        "city": "Madrid",
        "label": "Spain ID Card Address",
        "region": "Madrid",
        "street_1": "Avda de Madrid 34",
        "street_2": null,
        "postal_code": "28822",
        "raw_results": {
          "geometry": {
            "location": {
              "lat": 37.4222804,
              "lng": -122.0843428
            },
            "location_type": "ROOFTOP",
            "viewport": {
              "northeast": {
                "lat": 37.4237349802915,
                "lng": -122.083183169709
              },
              "southwest": {
                "lat": 37.4210370197085,
                "lng": -122.085881130292
              }
            }
          },
        },
      },
      "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, which is necessary for document verification. This could be due to poor image quality or an unsupported document type.",
        }
      ],
    },
    // optional field if NFC feature is enabled
    "nfc": {
      "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, Serial Number: 3, Organization: DIRECCION GENERAL DE LA POLICIA, Country: ES",
        "subject": "Common Name: DS n-eID SPAIN 2, Organizational Unit: PASSPORT, Organization: DIRECCION GENERAL DE LA POLICIA, Country: ES",
        "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 Optical Character Recognition (OCR) data and the NFC chip data don't match, indicating potential document tampering or data inconsistency."
        }
      ],
    },
    // optional field if LIVENESS feature is enabled
    "liveness": {
      "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,
      "warnings": [
        {
          "risk": "LOW_LIVENESS_SCORE",
          "additional_data": null,
          "log_type": "information",
          "short_description": "Low liveness score",
          "long_description": "The liveness check resulted in a low score, indicating potential use of non-live facial representations or poor-quality biometric data."
        }
      ]
    },
    // optional field if FACE_MATCH feature is enabled
    "face_match": {
      "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 of the provided image don't closely match the reference image, suggesting a potential identity mismatch."
        }
      ]
    },
    // optional field if PHONE feature is enabled
    "phone": {
      "status": "Approved",
      "phone_number_prefix": "+34",
      "phone_number": "600600600",
      "full_number": "+34600600600",
      "country_code": "ES", // ISO 3166-1 alpha-2
      "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",
      "warnings": [],
    },
    // optional field if POA feature is enabled
    "poa": {
      "status": "Approved",
      "document_type": "Bank Statement",
      "issuer": "National Bank",
      "issue_date": "2024-05-15",
      "document_language": "EN",
      "name_on_document": "John A. Smith",
      "name_match_score": 92.5,
      "address": "123 Main St, Apartment 4B, New York, NY 10001",
      "formatted_address": "123 Main St, Apartment 4B, New York, NY 10001, USA",
      "parsed_address": {
        "street_1": "123 Main St",
        "street_2": "Apartment 4B",
        "city": "New York",
        "region": "NY",
        "postal_code": "10001",
        "raw_results": {
          "geometry": {
            "location": {
              "lat": 40.7128,
              "lng": -74.0060
            }
          }
        }
      },
      "document_file": "https://example.com/poa_document.pdf",
      "extra_files": [
        "https://example.com/extra_poa.pdf",
      ],
      "warnings": [],
    },
    // optional field if AML feature is enabled
    "aml": {
      "status": "In Review",
      "total_hits": 1,
      "hits": [
        {
          "id": "cl-info-probidad-3fd0f04facc53bd94ebec9aaedf56d18",
          "match": false,
          "score": 0.4,
          "target": true,
          "caption": "PABLO ALFONSO ESCOBAR NAVARRO",
          "datasets": ["cl_info_probidad"],
          "features": {
            "country_mismatch": 1.0,
            "person_name_phonetic_match": 0.67
          },
          "last_seen": "2024-10-28T02:50:03",
          "first_seen": "2024-01-17T02:50:01",
          "properties": {
            "name": ["PABLO ALFONSO ESCOBAR NAVARRO"],
            "topics": ["role.pep"],
            "country": ["cl"]
          }
        }
      ],
      "score": 40.0,
      "screened_data": {
        "full_name": "Pablo Escobar",
        "nationality": "COL",
        "date_of_birth": "1975-12-01",
        "document_number": "CAA000000"
      },
      "warnings": [
        {
          "risk": "POSSIBLE_MATCH_FOUND",
          "additional_data": null,
          "log_type": "warning",
          "short_description": "Possible match found in AML screening",
          "long_description": "The Anti-Money Laundering (AML) screening process identified potential matches with watchlists or high-risk databases, requiring further review."
        }
      ],
    },
    // optional field if IP_ANALYSIS feature is enabled
    "ip_analysis": {
        "status": "Approved",
        "device_brand": "Apple",
        "device_model": "iPhone",
        "browser_family": "Mobile Safari",
        "os_family": "iOS",
        "platform": "mobile",
        "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": [],
    },
    "reviews": [
      {
        "user": "compliance@example.com",
        "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"
  }
}

For a complete list of possible properties and their values for the decision field, please refer to our API Reference.