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

# Webhooks

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

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

```json theme={null}
{ "workspaceId": "<workspaceId>" }
```

Response (200): `{ ok: true, status: 200 }` for successful delivery. Failures respond with `{ ok: false, ... }` but still HTTP 200.

## Example handler

```typescript theme={null}
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 });
}
```
