Skip to main content
title: “Webhooks” description: “Receive submission and verification events for hosted KYC sessions” icon: “link” Webhooks notify your server about submission creation and final verification outcomes for hosted sessions.

Configure

  • Enable webhooks in Console → Workspace Settings → Notifications
  • Set webhookUrl (HTTPS)
  • A signing webhookSecret is stored per workspace
  • Choose events to receive

Security

Each request includes an HMAC signature header using your webhookSecret.
X-KYC-Signature: sha256=<hex-hmac-of-body>
Verify the signature by recomputing HMAC‑SHA256 over the exact raw body JSON string and comparing constant‑time to the header value.

Event delivery

  • Sent as JSON over HTTPS to your configured endpoint
  • At‑least‑once delivery — de‑duplicate using the id field
  • Events are filtered by your workspace webhookEvents allow‑list
  • Monitor deliveries in your logs; retries/backoff may be added in the future

Event types

  • submission.created — Emitted immediately after a submission is persisted
  • verification.completed — Hosted session finished with pass
  • verification.failed — Hosted session finished with fail

KYC status

In addition to the legacy session lifecycle (not_started → processing → completed/failed), webhook events include a high-level KYC status for business decisions:
  • Approved: AI pass and auto-approve enabled
  • Submitted: AI pass and auto-approve disabled (awaiting manual decision)
  • Declined: AI fail, or manual rejection

Payload shape

{
  "type": "submission.created | verification.completed | verification.failed",
  "id": "<eventType>:<submissionId>",
  "timestamp": "2025-08-10T21:50:43.235Z",
  "status": "Approved | Submitted | Declined",
  "data": {
    "session": {
      "id": "<sessionId>",
      "status": "Approved | Submitted | Declined",
      "kycStatus": "not_started | processing | completed | failed",
      "redirectUrl": "https://...",
      "metadata": {},
      "createdAt": { "_seconds": 0, "_nanoseconds": 0 },
      "updatedAt": { "_seconds": 0, "_nanoseconds": 0 },
      "images": {
        "documentFrontUrl": "https://...",
        "documentBackUrl": "https://...",
        "selfieUrls": ["https://...", "https://...", "https://..."]
      }
    },
    "submission": {
      "id": "<submissionId>",
      "sessionId": "<sessionId>",
      "workspaceId": "<workspaceId>",
      "pass": true,
      "expectedDocumentType": "passport | id-card",
      "detectedDocumentType": "passport | id-card | unknown",
      "document": {},
      "face": {},
      "overall": { "pass": true, "reasons": [], "user_message": null },
      "kycStatus": "Approved | Submitted | Declined",
      "createdAt": { "_seconds": 0, "_nanoseconds": 0 }
    }
  }
}
We do not include raw image bytes in webhook payloads. Use the signed URLs for access control or copy to your storage if needed.

Test delivery

Use the API to trigger a test event:
POST /v1/kyc/webhooks/test
Body:
{ "workspaceId": "<workspaceId>" }
Response (200): { ok: true, status: 200 } for successful delivery. Failures respond with { ok: false, ... } but still HTTP 200.

Example handler

import type { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';

function verifySignature(rawBody: string, header: string | undefined, secret: string) {
  if (!header) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.status(405).end();

  const signature = req.headers['x-kyc-signature'] as string | undefined;
  const secret = process.env.KYC_WEBHOOK_SECRET || '';
  const raw = (req as any).rawBody ?? JSON.stringify(req.body);
  if (!verifySignature(raw, signature, secret)) return res.status(401).json({ error: 'invalid_signature' });

  const evt = req.body as { type: string; id: string; timestamp: string; data: any };

  // idempotent handling
  // upsert evt.id to a processed store before acting

  switch (evt.type) {
    case 'submission.created':
      break;
    case 'verification.completed':
      break;
    case 'verification.failed':
      break;
    default:
      // ignore
  }

  return res.status(200).json({ received: true });
}