Webhook events
Complete reference for every Certivant webhook event type — envelope schema, per-event payload shapes, idempotency pattern, and ordering rules.
Every webhook payload shares the same outer envelope. Only the data block changes per event type.
{
"event": "kyc.request.approved",
"requestId": "req_01J...",
"organizationId": "org_01H...",
"externalRef": "user-9281",
"subTenantName": "Acme Corp",
"subTenantExternalId": "cust_8842",
"status": "APPROVED",
"timestamp": "2026-05-27T10:04:21.000Z",
"data": { /* event-specific */ }
}
| Field | Type | Notes |
|---|---|---|
event | string | The event type. Always begins with kyc. |
requestId | string | The Certivant request ID |
organizationId | string | The org that owns the request |
externalRef | string | null | The foreign key you set on creation |
subTenantName | string | null | Partner mode: the end-customer display name set on the request, if any |
subTenantExternalId | string | null | Partner mode: your end-customer id set on the request — route the event to the right customer without a lookup |
status | enum | Current request status at the time of the event |
timestamp | string (ISO-8601) | When the event was queued |
data | object | null | Event-specific payload (see below) |
Event catalogue
kyc.webhook.test
A connectivity check — sent when you press Test on a webhook URL (tenant-wide under KYC Settings → Developer, or per-form in the form editor). It is not a real verification event: it carries no requestId and changes nothing on your side. Its only purpose is to confirm your endpoint is reachable.
It includes an explicit top-level test: true flag so your handler can short-circuit and reply 2xx immediately — without looking up a request, checking the status, or even verifying the signature (there is nothing to protect, since a ping mutates no state).
{
"event": "kyc.webhook.test",
"test": true,
"organizationId": "org_...",
"formId": "form_...", // present only for the per-form test
"formName": "KYC — Identity Verification",
"timestamp": "...",
"data": { "message": "This is a test webhook from your KYC platform." }
}
Recommended handler:
const payload = JSON.parse(rawBody);
// Reachability ping — ack immediately, no auth needed.
if (payload.test === true || payload.event === "kyc.webhook.test") {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
// …verify the signature and process real events below…
Real result events (a status in the lifecycle enum) should still be signature-verified before you act on them — see Signature verification. The test event is signed too (when a secret is set) — with the same X-Certivant-Signature-V2 header as real events, and a unix-seconds timestamp — so you may verify it, but you are not required to.
The dashboard's Send a test event button can also send a sample kyc.request.created, kyc.request.approved, kyc.request.rejected or kyc.request.escalated payload. Samples have the real envelope shape, carry "test": true, use a fake requestId (req_sample_…) and data.sample: true, and are never stored as real events. Don't act on them in production code paths: branch on payload.test === true first.
kyc.request.created
Fires immediately after POST /api/v1/kyc/requests returns 201. Useful if you want to confirm receipt without waiting for the applicant to submit.
{
"event": "kyc.request.created",
"requestId": "req_...",
"organizationId": "org_...",
"externalRef": "user-9281",
"status": "PENDING_UPLOAD",
"timestamp": "...",
"data": null
}
kyc.request.processing
Fires the moment the request transitions to PROCESSING — i.e. the applicant (via a hosted session or a form submission) or you (via POST /requests/:id/submit) submitted it. This is the "submitted" signal: it arrives before any decision, so your app can flip the user to an "under review" state as soon as they finish uploading.
{
"event": "kyc.request.processing",
"requestId": "req_...",
"externalRef": "user-9281",
"status": "PROCESSING",
"timestamp": "...",
"data": null
}
Act on it when you want to show progress ("Your verification is being reviewed") between submission and the final approved / rejected / escalated event. It carries externalRef and the sub-tenant fields, so you can route it to the right user without a lookup.
kyc.document.uploaded
Fires whenever a document is attached to a request. Useful if your app shows a real-time "documents received so far" indicator.
{
"event": "kyc.document.uploaded",
"requestId": "req_...",
"status": "PENDING_UPLOAD",
"data": {
"documentId": "doc_...",
"documentType": "GOVERNMENT_ID",
"side": "front",
"mimeType": "image/jpeg",
"fileSize": 412938
}
}
kyc.check.completed
Fires after each verification check finishes. A single request can produce multiple of these — one for document authenticity, one for face match, one for liveness, etc.
{
"event": "kyc.check.completed",
"requestId": "req_...",
"status": "PROCESSING",
"data": {
"checkType": "face_match",
"result": "pass",
"score": 0.91,
"details": { "method": "deep_face_match" }
}
}
Possible checkType values: document_authenticity, ocr, face_match, liveness, sanctions_screening, pep_screening, adverse_media, proof_of_address, phone_verification, email_verification, registry_lookup (KYB).
kyc.request.approved
The big one — the request is approved. Fires both when the automated engine clears it and when a human reviewer manually approves an escalated (or previously auto-rejected) request from the dashboard or the /decision API. For manual approvals data.decision is "approve" and data.reason carries the reviewer's note; the rich engine fields (score, factors, …) are present only on automated decisions.
{
"event": "kyc.request.approved",
"requestId": "req_...",
"status": "APPROVED",
"data": {
"score": 94,
"decision": "approve",
"factors": [
{ "name": "document_authenticity", "weight": 0.3, "score": 0.96 },
{ "name": "face_match", "weight": 0.3, "score": 0.91 },
{ "name": "liveness", "weight": 0.2, "score": 0.98 },
{ "name": "sanctions_screening", "weight": 0.2, "score": 1.0 }
],
"document": {
"type": "GOVERNMENT_ID",
"issuingCountry": "GB",
"expiryDate": "2031-04-12",
"isExpired": false
},
"extracted": {
"fullName": "Alex Singh",
"dateOfBirth": "1988-11-03",
"documentNumber": "GBR-1234567",
"address": "1 Example Street, London",
"nationality": "GB"
},
"liveness": { "isLive": true, "confidence": 0.98 },
"faceMatch": { "isMatch": true, "confidence": 0.91 },
"sanctions": { "isMatch": false, "topScore": 0, "listsChecked": ["OFAC", "UN", "EU", "UK_HMT"] }
}
}
The data.extracted block is your goldmine — save those values against the user record so you don't ask the applicant for the same data twice.
kyc.request.rejected
The request is rejected — either the automated engine rejected it (look at data.disqualifiers and data.factors for the reasons) or a human reviewer manually rejected an escalated request (data.decision = "reject", data.reason = the reviewer's note).
{
"event": "kyc.request.rejected",
"requestId": "req_...",
"status": "REJECTED",
"data": {
"score": 18,
"decision": "reject",
"disqualifiers": ["document_expired", "liveness_failed"],
"reviewTriggers": [],
"reason": "Document expired (2024-01-01) and liveness confidence below threshold (0.22).",
"factors": [
{ "name": "document_authenticity", "weight": 0.3, "score": 0.0, "verdict": "fail" },
{ "name": "liveness", "weight": 0.2, "score": 0.22, "verdict": "fail" }
],
"document": {
"type": "GOVERNMENT_ID",
"expiryDate": "2024-01-01",
"isExpired": true
},
"liveness": { "isLive": false, "confidence": 0.22 }
}
}
You can resubmit the request via POST /requests/:id/resubmit if your policy allows.
kyc.request.escalated
The engine wasn't confident — borderline scores, sanctions partial-match, low-quality document, or anything that crossed your org's escalation threshold. A human in your organization needs to review.
{
"event": "kyc.request.escalated",
"requestId": "req_...",
"status": "ESCALATED",
"data": {
"score": 62,
"decision": "escalate",
"reviewTriggers": ["sanctions_partial_match", "low_face_match_confidence"],
"factors": [...],
"sanctions": {
"isMatch": true,
"topScore": 0.72,
"listsChecked": ["OFAC", "UN", "EU", "UK_HMT"]
},
"faceMatch": { "isMatch": true, "confidence": 0.68 }
}
}
A reviewer opens the request in the dashboard and decides. When they do, you get a second webhook — kyc.request.approved or kyc.request.rejected — with the human decision noted in the audit log.
kyc.request.cancelled
Fires when a request is explicitly cancelled (currently only via the dashboard — no API endpoint).
{
"event": "kyc.request.cancelled",
"requestId": "req_...",
"status": "CANCELLED",
"data": { "reason": "applicant_withdrew", "cancelledBy": "user_..." }
}
Live verification events — conference.*
Live ID verification (human-hosted live video session) events use their own,
conference-shaped body — conferenceId, kycRequestId, applicantId,
organizationId, your externalRef, the conference status and timestamps —
instead of the kyc.request.* envelope above. They fire for conferences
scheduled from the dashboard and from the API alike.
| Event | Fires when |
|---|---|
conference.scheduled | A live verification is created. |
conference.rescheduled | It moves to a new time (previousScheduledFor). |
conference.cancelled | It is cancelled — by API, or because the applicant refused consent (cancellation). |
conference.started | The host starts the session (once per start). |
conference.completed | The host records the decision (decision.decision, decision.outcome, notes, recording flag, certificate). |
{
"event": "conference.completed",
"conferenceId": "cmf...",
"kycRequestId": "req_...",
"applicantId": "cma...",
"organizationId": "org_...",
"externalRef": "order-10042",
"status": "completed",
"decision": { "decision": "verified", "outcome": "approved", "requestStatus": "APPROVED", "notes": "..." },
"timestamp": "..."
}
Full payload and the scheduling API: Live verification API.
Idempotency
Certivant may deliver the same event more than once (network blips, retries — see Retries). Make your handler idempotent.
The header X-Certivant-Event-Id (also sent as X-KYC-Event-Id) is unique per event. Common pattern:
async function handleWebhook(rawBody: string, headers: Headers) {
const eventId = headers.get("X-KYC-Event-Id")!;
// Skip if already processed
const seen = await db.webhookEvent.findUnique({ where: { id: eventId } });
if (seen) return;
const payload = JSON.parse(rawBody);
// Do the work + record the event in a single transaction
await db.$transaction([
db.webhookEvent.create({ data: { id: eventId, eventType: payload.event } }),
...yourBusinessLogic(payload),
]);
}
X-KYC-Event-Id is stable across retries — the same logical event keeps the same id no matter how many delivery attempts happen.
Ordering
Events for different requests can arrive in any order. Events for the same request are queued in the order they were generated, but retries can interleave — for example, a stuck processing event might arrive after an approved event for the same request.
Defensive pattern: always use the status field as the source of truth, and ignore events whose status is "older" than what you've already recorded. The lifecycle is strictly forward (PENDING_UPLOAD → PROCESSING → APPROVED/REJECTED/ESCALATED), so if your DB says the request is APPROVED and a delayed kyc.request.processing event arrives, drop it.
Testing event handlers locally
Two dashboard buttons help here:
- Test (next to any webhook URL, tenant-wide or per-form) sends a
kyc.webhook.testconnectivity ping (test: true). Use it to confirm your endpoint is reachable and returns2xx— it needs no request data and no configured secret. - Send test event sends a full
kyc.request.approvedpayload with sample data, so you can exercise your real result-handling path.
To test other event types, write a small script that builds the same envelope and POSTs to your dev URL using a tunnel:
# tunnel localhost:3000 to a public URL
ngrok http 3000
# Then in the dashboard webhook settings, paste the ngrok URL,
# trigger real test requests via the API, and watch your logs.
Signature verification
Verify the X-Certivant-Signature-V2 header (HMAC-SHA256 over timestamp.body, 300 s tolerance) so you trust webhook payloads. Includes Node, Python, Go, Ruby, PHP examples.
Retries and delivery
How Certivant retries failed webhook deliveries — exact backoff schedule, 10-second timeout rule, exhausted-event recovery, and a fast handler pattern to stay under budget.