Webhooks
In brief: Endpoint registration, payload format, HMAC signing, retry policy, idempotency.
Webhooks push verification results to your system the moment they land. Without webhooks, you have to poll the API — fine for testing, not fine in production.
Where to configure
/verify-app/webhooks in the dashboard.
The page shows:
- Registered endpoints — every URL you've added.
- Recent deliveries — last ~100 send attempts, with response codes, latency, and retry counts.
Adding an endpoint
- Click Add endpoint.
- Enter the URL — must be HTTPS, must respond with
2xxwithin 10 seconds. - Pick the events you want to receive (you can change later):
verification.completed— final outcome (most useful).verification.failed— provider error, expiry, decline.verification.started— subject opened the link, in progress.pass_share.requested— Pass holder is being offered to share with you (Pass interop).pass_share.approved/pass_share.declined— their decision.
- Click Add.
The dashboard generates a signing secret (whsec_...) which it
shows once. Copy and store it — same rules as
API keys. We hash it; we cannot recover it.
Payload shape
Every event has the same envelope:
{
"id": "evt_01HXYZ...",
"type": "verification.completed",
"created_at": "2026-05-28T12:05:32Z",
"data": {
"id": "ver_01HXYZ...",
"status": "completed",
"outcome": { "result": "pass", "age_verified": true, ... },
"subject": { ... },
"reference": "cart-abc-123",
"mode": "live",
"completed_at": "2026-05-28T12:05:32Z"
}
}
The data shape matches the verification object you get from
GET /v2/verify/{id} — see Sending a verification.
Verifying the signature
Every webhook delivery includes two headers:
X-Certivus-Signature: t=1716902732,v1=5257a869...
X-Certivus-Event-Id: evt_01HXYZ...
Verify the signature on every request — otherwise anyone can post to your webhook URL and pretend to be Certivus.
Pseudocode:
function verifySignature(rawBody, header, secret) {
const [tPart, sigPart] = header.split(",");
const timestamp = tPart.split("=")[1];
const signature = sigPart.split("=")[1];
// Reject if the timestamp is more than 5 minutes old (replay protection)
if (Math.abs(Date.now()/1000 - parseInt(timestamp)) > 300) {
throw new Error("Stale webhook");
}
// Recompute the signature
const payload = `${timestamp}.${rawBody}`;
const expected = hmacSha256(secret, payload);
// Constant-time comparison
if (!constantTimeEqual(signature, expected)) {
throw new Error("Bad signature");
}
}
Always use the raw request body — not a parsed-then-serialised version. Any reformatting (whitespace, key order) will break the HMAC.
Retry policy
If your endpoint doesn't return 2xx within 10 seconds, we retry:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |
After 7 failed attempts (~31 hours of trying), the delivery is marked failed and we stop. You can manually replay a failed delivery from the deliveries log.
Idempotency on your side
We may deliver the same event more than once (network blip, your endpoint times out then succeeds, etc.). Build your handler to be idempotent:
- Track the
X-Certivus-Event-Idheader. - If you've seen the event ID before, return
200immediately and do nothing. - Otherwise process the event, then mark the ID as seen.
Same event.id = same payload. Safe to dedupe on ID alone.
Order of delivery
Events are delivered roughly in order but not guaranteed. If you
care about ordering (e.g. you want verification.started to land
before verification.completed), don't trust the wire — use the
created_at timestamp in the payload, or just check the verification's
current state via the API when you receive any event.
Testing locally
Two options for local development:
- Use a tunnel —
ngrok,cloudflared,localtunnel, etc. Add the tunnel URL as your webhook endpoint. - Use the deliveries log replay — register a placeholder
endpoint (e.g.
https://httpbin.org/post), trigger a check, then replay the recorded delivery againstlocalhostfrom the deliveries log UI.
Common failure modes
| Symptom | Likely cause |
|---|---|
Bad signature errors | You're using a parsed body or your secret is wrong. Use raw bytes; double-check the secret. |
Endpoint returns 500 intermittently | We'll retry. Check your error monitoring. After 7 fails we stop. |
| Endpoint is fine but Certivus says timeout | Your endpoint took > 10s. Acknowledge first (return 200), process async. |
| Same event delivered multiple times | Expected. Dedupe on X-Certivus-Event-Id. |
Disabling an endpoint
Open the endpoint detail → Disable. Disabled endpoints stay in the list (greyed) but receive no events. Re-enable any time. Delete removes the endpoint and its delivery history.
Didn't find what you needed?
Contact support