Help Center/Webhooks

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

  1. Click Add endpoint.
  2. Enter the URL — must be HTTPS, must respond with 2xx within 10 seconds.
  3. 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.
  4. 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:

AttemptDelay after previous
1immediate
21 minute
35 minutes
415 minutes
51 hour
66 hours
724 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-Id header.
  • If you've seen the event ID before, return 200 immediately 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:

  1. Use a tunnelngrok, cloudflared, localtunnel, etc. Add the tunnel URL as your webhook endpoint.
  2. Use the deliveries log replay — register a placeholder endpoint (e.g. https://httpbin.org/post), trigger a check, then replay the recorded delivery against localhost from the deliveries log UI.

Common failure modes

SymptomLikely cause
Bad signature errorsYou're using a parsed body or your secret is wrong. Use raw bytes; double-check the secret.
Endpoint returns 500 intermittentlyWe'll retry. Check your error monitoring. After 7 fails we stop.
Endpoint is fine but Certivus says timeoutYour endpoint took > 10s. Acknowledge first (return 200), process async.
Same event delivered multiple timesExpected. 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