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.
| Event | Fired when |
|---|---|
| verification:created | A verification session has been created. Fired immediately by POST /verifications. |
| verification:completed | The verification reached a final verdict. This is the event to act on. |
| verification:manual_required | The session was escalated to a human reviewer. Not final, because a completed event follows. |
| verification:revoked | A previously issued verification was revoked. |
| kyc:created | A KYC session has been created. |
| kyc:completed | The KYC session reached a final verdict. |
| kyc:manual_required | The KYC session was escalated to a human reviewer. |
Payload
{ "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-timestampis Unix seconds at send time.webhook-signatureis a space-separated list ofv1,<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.
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:
{ "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.
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.
| Attempt | Sent after previous |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 12 hours |
| 6th and after | every 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
2xxas 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.
301and302responses are not followed (the one exception is a same-hosthttptohttpsupgrade), so a redirect is a failed delivery. x-idproval-attempt-idis 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 iswebhook-id.- Return non-
2xxonly when you genuinely want a retry. - Never return
2xxfor a payload that failed signature verification. Return400.
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.