Skip to content

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

EventWhendata
session.status_updatedEvery status transition of every session in the appsession_id, status, previous_status, vendor_data, workflow_id, decision
pingSend test in the consoleapp_id, environment, message

Headers

http
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

json
{
  "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

x-kyc-timestamp+"."+raw request body (bytes)
  1. Signed message

    1767225600.{"event":…}

  2. HMAC-SHA256

    keyed with whsec_… (per app)

  3. Hex digest

    64 lowercase hex characters

  4. 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
x-kyc-signature is v1= followed by the hex HMAC-SHA256 of the timestamp, a dot and the raw body.

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

  1. Attempt 1

    0

    on the status change

  2. Attempt 2

    +30 s

    30 s after the last

  3. Attempt 3

    +2 m 30 s

    2 min after the last

  4. Attempt 4

    +12 m 30 s

    10 min after the last

  5. Attempt 5

    +42 m 30 s

    30 min after the last

  6. Attempt 6

    +1 h 42 m

    1 h after the last

  7. Attempt 7

    +4 h 42 m

    3 h after the last

  8. 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

Eight attempts over about 10 h 43 min, then the delivery is marked failed.
  • 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 2xx quickly and do the work asynchronously; a slow handler is a failed attempt.
  • Deliveries can repeat. Use delivery_id (also in x-kyc-delivery) to make your handler idempotent.
  • Events for one session can arrive out of order after retries. Use status and previous_status, or fetch the session, rather than counting events.
  • Treat in_review as 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.