Docs

Core concepts

Webhooks

Verdicts arrive at your endpoint as signed HTTP POSTs. This page covers the payload shape, how to verify a signature, and exactly how retries behave.

Why webhooks are the source of truth

A user can close the tab, lose signal, or never return to your site after finishing a verification. The webhook is delivered regardless. Treat the browser redirect as a UX nicety and the webhook as the record.

Event types

Configure which events an endpoint receives in Settings → Webhooks. Every payload has the same envelope: an event name, an occurred_at timestamp, and a data object.

EventFired when
verification:createdA verification session has been created. Fired immediately by POST /verifications.
verification:completedThe verification reached a final verdict. This is the event to act on.
verification:manual_requiredThe session was escalated to a human reviewer. Not final, because a completed event follows.
verification:revokedA previously issued verification was revoked.
kyc:createdA KYC session has been created.
kyc:completedThe KYC session reached a final verdict.
kyc:manual_requiredThe KYC session was escalated to a human reviewer.

Payload

verification:completed
{ "event": "verification:completed", "occurred_at": "2026-05-06T14:24:55Z", "data": { "verification": { "id": "vrfy_01jt…", "external_id": "order_abc123", "status": "completed", "created_at": "2026-05-06T14:22:01Z", "completed_at": "2026-05-06T14:24:55Z", "result": { "verdict": "auto_approved", "document": { "type": "passport", "face_match": true, "age_match": true, "dob": "1990-01-01" } } } } }

verdict is auto_approved, auto_rejected, manual_approved or manual_rejected. It can be null on non-final events, so always branch on the event name first and the verdict second.

Verifying the signature

Deliveries follow the Standard Webhooks specification, so any conforming library will validate them. Three headers are sent:

  • webhook-id: stable ID for this delivery. It is identical across every retry of the same event, and it is the value the signature is computed over, so it is the right idempotency key.
  • webhook-timestamp is Unix seconds at send time.
  • webhook-signature is a space-separated list of v1,<base64> signatures.

The signed content is {'{'}webhook-id{'}'}.{'{'}webhook-timestamp{'}'}.{'{'}raw body{'}'}, HMAC-SHA256, base64-encoded.

One gotcha worth reading twice. Standard Webhooks libraries expect the signing key to be base64-encoded (the familiar whsec_… form). The secret shown in your IDProval dashboard is the raw string, so you must base64-encode it before handing it to the library. Skipping this is the single most common reason signature checks fail.

webhook.ts
import { Webhook } from 'standardwebhooks'; // Base64-encode the raw secret from the dashboard.const key = Buffer.from(process.env.IDPROVAL_WEBHOOK_SECRET).toString('base64'); const wh = new Webhook(key); app.post('/webhooks/idproval', express.raw({ type: 'application/json' }), (req, res) => { let payload; try { // Must be the RAW body. A parsed-and-restringified object will not match.payload = wh.verify(req.body, req.headers); } catch { return res.status(400).end(); } // Acknowledge fast, then do the slow work out of band.enqueue(payload); res.status(200).end(); });

Verify against the raw request bytes. Any middleware that parses JSON and re-serialises it will change whitespace or key order and break the signature. In Express that means express.raw() on this route, not the global express.json().

Encrypted payloads

Payloads carry identity data — name, date of birth, document number, personal number. They are encrypted at rest so a stolen database backup cannot expose them, and the encryption is directed at a key only you hold: we keep the public half and never store the private one.

The key exchange is ML-KEM-1024 (FIPS 203) and the payload itself is sealed with AES-256-GCM. ML-KEM is post-quantum, which is the point for data like this: a delivery captured today must not become readable when a quantum computer arrives. Deliveries arrive as an envelope:

encrypted delivery
{ "event": "kyc:completed", "occurred_at": "2026-05-06T14:24:55Z", "idproval_encrypted": { "v": 1, "alg": "ML-KEM-1024+A256GCM", "ct": "<base64>", "iv": "<base64>", "tag": "<base64>", "data": "<base64 ciphertext>" } }

event stays in the clear so you can route without decrypting. Everything else, including all the identity fields, is inside data.

Verification comes first, and that is the point. The signature covers the encrypted bytes, so the sender has to be authenticated before the envelope is opened. verifyWebhookSignature does both in one call, in that order.

webhook.ts
import { verifyWebhookSignature } from '@id-proval/sdk/server'; // One call verifies the signature and decrypts. The private key is the one you// copied when the webhook was created.const payload = verifyWebhookSignature( req.body, req.headers, process.env.IDPROVAL_WEBHOOK_SECRET, process.env.IDPROVAL_WEBHOOK_PRIVATE_KEY, );

The private key is shown once, when the webhook is created. It is the ML-KEM seed in base64 — a single line, so it fits comfortably in an environment variable. Rotate it from the webhook's detail page if you lose it, but note that deliveries already sent stay encrypted to the old key and cannot be recovered with the new one.

A webhook created before payload encryption existed has no key and still delivers plaintext. The private key argument is optional for exactly that case, and the same call returns the verified payload unchanged. An encrypted delivery with no key supplied throws instead of handing you ciphertext that would look like a payload.

Retry schedule

A delivery counts as successful on any 2xx response. Anything else, whether a 4xx, a 5xx, a timeout or a connection error, is a failure and will be retried.

AttemptSent after previous
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry12 hours
6th and afterevery 24 hours

The first send is immediate. Backoff is measured from the last attempt, not from when the event was created.

Retries stop after 12 attempts. On the ladder above that is 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and then once every 24 hours, roughly eight days of trying in total. After the twelfth failed attempt the delivery is abandoned and is never selected again, and nothing further is sent for that event. An endpoint that is down for a day loses nothing; an endpoint that is down for a week does. If you need to be sure, reconcile with GET /v1/verifications/:id rather than relying on retries alone.

Delivery history, response codes and attempt counts are visible per endpoint in Settings → Webhooks.

Idempotency and ordering

Because failures are retried, your endpoint will see the same event more than once eventually, and a timeout after you already committed the work is enough. Make handling idempotent: key on webhook-id, or on the verification id plus event, and ignore anything already processed.

Do not assume ordering. A retried verification:created can land after verification:completed. Use occurred_at to discard stale state rather than overwriting a newer verdict with an older one.

Responding well

  • Return 2xx as soon as you have stored the event. Queue the real work.
  • Respond within 15 seconds. A slower response is abandoned mid-flight, recorded as a failed attempt and retried, which produces duplicates.
  • Point the webhook at its final URL. 301 and 302 responses are not followed (the one exception is a same-host http to https upgrade), so a redirect is a failed delivery.
  • x-idproval-attempt-id is a fresh UUID on every attempt. Use it to correlate one specific attempt in your logs with ours; do not use it for idempotency, that is webhook-id.
  • Return non-2xx only when you genuinely want a retry.
  • Never return 2xx for a payload that failed signature verification. Return 400.

Testing locally

Point an endpoint at a tunnel (ngrok, Cloudflare Tunnel, or similar) and run a verification end to end. Failed attempts can be inspected and replayed from the dashboard, which is usually faster than reproducing the whole flow again.

Stuck on this page?

Email contact@idproval.com and a real engineer will pick it up.

Business support