API
Webhooks
Signed session events with persisted retries, in five languages.
KYCVerify sends POST {webhook_url} for every session status change and for ping tests. The URL is set per app in the console. Each app has its own signing secret, which looks like whsec_…; the full value is shown once, when the app is created or the secret is rotated, and masked everywhere else.
Events
| Event | When | data |
|---|---|---|
session.status_updated | Every status transition of every session in the app | session_id, status, previous_status, vendor_data, workflow_id, decision |
ping | Send test in the console | app_id, environment, message |
Headers
POST /webhooks/kyc HTTP/1.1
content-type: application/json
user-agent: KYCVerify-Webhooks/0.1.0
x-kyc-event: session.status_updated
x-kyc-delivery: whd_0k3v9z7c2b5mxk1q8hes
x-kyc-timestamp: 1767225600
x-kyc-signature: v1=5f2c…Body
{
"event": "session.status_updated",
"delivery_id": "whd_0k3v9z7c2b5mxk1q8hes",
"created_at": "2026-10-03T09:12:44Z",
"data": {
"session_id": "ses_0k3v9x2m4a7qhd8f1rtb",
"status": "approved",
"previous_status": "processing",
"vendor_data": "user-42",
"workflow_id": "wf_0k3t1c8n5e2wpzr6g4ya",
"decision": { "…": "the Decision object when the status is final, else null" }
}
}decision is the same object as GET /v1/sessions/{id}/decision when the new status is final (approved, declined, in_review), and null for intermediate states.
Verifying the signature
Signed message
1767225600.{"event":…}
HMAC-SHA256
keyed with whsec_… (per app)
Hex digest
64 lowercase hex characters
v1=<hex>
sent as x-kyc-signature
- Recompute over the raw body, before parsing JSON
- Compare in constant time
- Reject timestamps more than 300 s from now
Compute it over the raw request body, before any JSON parsing, compare in constant time, and reject a timestamp more than 5 minutes from now. Each attempt is signed at send time, so a retry carries a fresh timestamp.
Verify x-kyc-signature
import { createHmac, timingSafeEqual } from "node:crypto";
// x-kyc-signature: v1=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>
export function verifyKycWebhook(rawBody, headers, secret) {
const ts = headers["x-kyc-timestamp"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${ts}.`)
.update(rawBody) // the raw Buffer, before JSON.parse
.digest("hex");
const given = String(headers["x-kyc-signature"] ?? "").replace(/^v1=/, "");
return given.length === expected.length &&
timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}Delivery and retries
Attempt 1
0
on the status change
Attempt 2
+30 s
30 s after the last
Attempt 3
+2 m 30 s
2 min after the last
Attempt 4
+12 m 30 s
10 min after the last
Attempt 5
+42 m 30 s
30 min after the last
Attempt 6
+1 h 42 m
1 h after the last
Attempt 7
+4 h 42 m
3 h after the last
Attempt 8
+10 h 42 m
6 h after the last
Any 2xx within 10 s
delivery succeeded; no more attempts
Anything else
non-2xx, timeout, refused: next attempt is scheduled
After attempt 8 fails
marked failed; redeliver from the console
- A delivery succeeds on any 2xx response within 10 seconds. Redirects are not followed.
- Otherwise it is retried: 8 attempts in total, the first immediately, then 30 s, 2 min, 10 min, 30 min, 1 h, 3 h and 6 h after the previous one.
- The queue lives in the database, so restarts do not lose deliveries, and each attempt is signed with the app's current secret.
- Every delivery, its status code, error and the first 256 bytes of your response are listed under Webhooks in the console, where an admin can redeliver it.
Where deliveries may go
Outside development mode the delivery worker resolves the host first and refuses to connect if any address is loopback, private, link-local, shared (100.64.0.0/10), otherwise not public, or one of the service's own addresses, then pins the connection to the addresses it checked so a second DNS answer cannot redirect it. The same check runs when you save the webhook URL. Point webhooks at a public HTTPS endpoint.
Handling events well
- Respond
2xxquickly and do the work asynchronously; a slow handler is a failed attempt. - Deliveries can repeat. Use
delivery_id(also inx-kyc-delivery) to make your handler idempotent. - Events for one session can arrive out of order after retries. Use
statusandprevious_status, or fetch the session, rather than counting events. - Treat
in_reviewas not decided yet; the reviewer's decision arrives as another event. - Rotate the secret in the console if it leaks. The new secret is shown once and applies to every pending retry.