Skip to main content

Retries

Delivery is at least once. Plan for duplicates, not for exactly-once.

How a delivery is produced

When a verdict is reached — by the pipeline or by a human reviewer in the dashboard — the event is written to a delivery outbox in the same database transaction as the verdict itself. A separate service, veridia-webhooks, reads that outbox and makes the HTTP calls.

That structure is what gives you the guarantee that matters: a verdict cannot exist without its event, and an event cannot exist for a verdict that rolled back. It is also why the request does not arrive from inside the process that computed the verdict — a dispatcher can retry and back off; a request handler waiting on your endpoint cannot.

The schedule

Six attempts. One immediate, then five retries:

AttemptWait before itElapsed since the first attempt
10 s
21 s1 s
35 s6 s
430 s36 s
52 min156 s
610 min756 s

Total window: 756 seconds, about 12.6 minutes. That is how long your endpoint can be down before an event stops being retried on its own. Size maintenance windows against 12.6 minutes, not against the raw attempt count.

An attempt is retried on 5xx, a timeout (10 s total, 5 s to connect), a connection failure, or a 408/429. Any other 4xx is a permanent failure and stops the schedule immediately — a 401 or a 404 will not become a 200 on the fourth try, and retrying only delays your discovering the misconfiguration.

Every attempt is signed fresh

The dispatcher recomputes the HMAC on each attempt, so the t in Veridia-Signature is the time of that attempt, not of the first one.

This answers the question the schedule above naturally raises: if the last retry can land 12.6 minutes after the event was created, and the recommended replay tolerance is 300 seconds, do late retries get rejected as replays?

No. The sixth attempt arrives with a timestamp seconds old. Keep the tolerance at 300 seconds. Widening it to cover the retry window buys you nothing and triples the interval in which a captured request can be replayed against you.

Duplicates are normal

The common duplicate is not a failure. It is a handler that processed the event correctly and then took too long to answer — the work is done, the acknowledgement missed the timeout, and the dispatcher, having no way to distinguish that from a dead endpoint, tries again.

Deduplicate on id:

const eventId = request.headers.get('Veridia-Event-Id'); // === payload.id
if (await alreadyProcessed(eventId)) return ok();

id is the evt_* value. It is stable across all six attempts of an event and distinct between events — including between two events for the same verification, which is exactly the case a verificationId-based key gets wrong. See Idempotency.

Record the id as part of the same transaction that applies the effect. Marking it processed before the work risks losing the event; marking it after risks doing the work twice.

After the last attempt

The delivery is marked failed and stops. It is not discarded: it stays in the outbox with its status, the last HTTP status code, and the last error, visible in the dashboard under Webhooks.

An operator can re-queue a failed delivery from there. Re-queuing resets the attempt counter to zero, so the event gets the full six-attempt window again. Only deliveries in failed state can be re-queued — an event still working through its schedule cannot be knocked back to the start by an impatient click.

A re-queued event carries the same id. If your handler did in fact process it before failing to acknowledge, your dedup check will recognize it and discard it. That is the intended behavior.

The dashboard also shows the outbox itself: one row per event with its current state, which is the view you want when the question is "did this verdict reach my customer, and if not, why."

The URL guard

The endpoint URL is validated twice, and both checks exist because a webhook body carries fieldsExtracted — full name, document number, date of birth.

At save time, the dashboard rejects any URL that does not begin with https://. There is no HTTP mode, no localhost exception, no test-mode relaxation. Identity data does not cross the network in plaintext.

At send time, the resolved address is checked against an SSRF guard, which rejects:

  • private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • loopback (127.0.0.0/8, ::1)
  • link-local, including cloud metadata endpoints (169.254.0.0/16)
  • carrier-grade NAT (100.64.0.0/10)

The resolution is re-checked on every send rather than once at save time. A hostname that resolved to a public address when you configured it can resolve to 169.254.169.254 later — DNS rebinding — and a guard that only ran at configuration time would not notice.

If you are wondering how to develop against this: you use a tunnel. See testing locally.

Recovering events you lost

If your endpoint was down longer than 12.6 minutes and you did not re-queue in time, the verdicts still exist. Reconcile with the API:

curl https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2 \
-H "Authorization: Bearer qv_sec_YOUR_SECRET_KEY"

This requires a secret key (qv_sec_ live, qv_sect_ test). A publishable key returns 401 secret_key_required.

Reading a verdict this way returns status and verdict as separate fields. status: "completed" means the pipeline finished — it does not mean the person passed. Branch on verdict.

For a reconciliation job you need the list of verification IDs you are missing, which means recording each verificationId at /v1/verify/init time, before any webhook exists. If you are not storing that mapping today, that is the gap to close first: without it there is no way to enumerate what you missed.

What's next