Webhooks
How Auvral tells your backend what happened: delivery, retries, idempotency, and why this is the only authoritative signal.
Webhooks are how your backend learns the outcome of a verification. Today they are the only way, so treat your webhook handler as the place where verification changes your system's state.
Set one up
Dashboard, Integrations, Webhooks, Create webhook.
| Field | Notes |
|---|---|
| Name | For your own reference. Up to 100 characters. |
| Webhook URL | Must be https://. |
| Flows | Which flows this webhook receives events for. At least one. |
| Signing secret | Leave blank to generate one, or supply your own. |
A webhook is subscribed to flows, not to event types: it receives all four event types
for verifications on that flow. Filter by type in your handler.
Reveal the signing secret from the webhook's page and store it alongside your API key. It is a separate credential with its own rotation.
Send test event delivers a webhook.test event with the same headers and signature
scheme as a real one. Use it to confirm reachability and to develop against.
The four events
type | When |
|---|---|
verification.started | Your user began the verification |
verification.submitted | Your user finished submitting; there is not yet a verdict |
verification.succeeded | Verdict: verified |
verification.failed | Verdict: rejected |
Plus webhook.test, sent only by the dashboard's test button.
The success event is verification.succeeded
Not verification.completed. If you are working from an older integration guide or an
example that mentions verification.completed or a flat { verificationId, sessionId, event } body, it is out of date. See Event reference.
verification.failed is a rejected verification, not a delivery failure. It is a
normal outcome.
Payload
Every event has the same envelope:
{
"id": "6f1d0a4c-8e22-4b7e-9a13-5c0be9f4d871",
"type": "verification.succeeded",
"createdAt": "2026-08-25T15:12:44.301Z",
"data": {
"verification": {
"id": "b47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "VERIFIED",
"flowId": "3f1c9a52-4d7e-4b3a-9c21-8e5f6a0b7d34",
"flowName": "Onboarding",
"sessionId": "9b2f4c1e-7a83-4d06-b5f1-2c9e8d3a6047",
"referenceId": "user_8842",
"metadata": { "orderId": "order-8841" },
"startedAt": "2026-08-25T15:11:02.884Z",
"endedAt": "2026-08-25T15:12:44.120Z",
"steps": [
{ "name": "document_verification", "status": "VERIFIED" },
{ "name": "biometric_verification", "status": "VERIFIED" }
]
}
}
}Match it to your user with data.verification.sessionId (the sessionId you stored when
you created the session) or data.verification.referenceId (your own id for the person).
The payload carries no personal data: no name, no document number, no image.
Full field-by-field detail in the Event reference.
Headers
| Header | Value |
|---|---|
x-auvral-signature | v1=<hex>, HMAC-SHA256 of `${timestamp}.${rawBody}` |
x-auvral-timestamp | Unix seconds; the same value that went into the signature |
x-auvral-event-id | The event's id. Stable across retries, deduplicate on this |
x-auvral-event-type | e.g. verification.succeeded |
x-auvral-delivery-id | This individual delivery attempt |
x-auvral-attempt | Attempt number |
user-agent | Auvral-Webhooks/2 |
Verify the signature. This is not optional.
Your webhook URL is reachable by anyone who learns it, and the signature is the only thing that separates a real Auvral event from a forged one claiming success.
Three rules, all of which have bitten real integrations:
- Read the raw body before parsing it. The signature covers exact bytes. Any framework that hands you a parsed object has already destroyed what you need: a re-serialized JSON object will not match.
- Reject stale timestamps. Without a freshness check, a captured request can be replayed against you forever. Five minutes is a reasonable window.
- Compare in constant time.
crypto.timingSafeEqualin Node,hmac.compare_digestin Python.
Verifying signatures has working code in Node, Python, Go, PHP and Ruby.
Delivery, retries and idempotency
- Success is any
2xx. Anything else, a4xx, a5xx, a timeout or a DNS failure, is a failed attempt. - Timeout is 10 seconds per attempt.
- Up to 6 attempts, with backoff of 1 minute, 5 minutes, 30 minutes, 2 hours, then 6 hours between them. An endpoint that is down for an afternoon still receives its events.
- Your response body is recorded and visible in the dashboard, so a useful error message makes your own debugging easier.
Make your handler idempotent
Retries reuse the same x-auvral-event-id, so a handler that is not idempotent can process
the same verification up to six times: six approval emails, six credited accounts.
Record the event id, and ignore one you have already processed.
Acknowledge fast. Return 2xx as soon as you have verified the signature and durably
recorded the event; do the real work afterwards, out of band. A handler that does slow
work inline will hit the 10-second timeout and get retried, which is how the duplicate
problem starts.
Events are not ordered: a retried verification.submitted can arrive after
verification.succeeded. Drive your logic from type and data.verification.status, not
from arrival order.
Monitoring
The webhook's page in the dashboard shows every delivery attempt (timestamp, HTTP status, response body, attempt number) and lets you retry a failed one by hand or pause the webhook without deleting it.
The browser never decides
The postMessage is a UI signal, not a proof
Never unlock an account, approve a payment or change business state from it. The truth is
the webhook or a server-side GET. It lives in the browser and the user controls their
browser.
The SDK's onResult callback arrives in your user's browser. It is genuinely
useful for updating your interface immediately, and genuinely forgeable by the person
sitting in front of it.
The split to hold onto:
| Signal | Trust it for | Never trust it for |
|---|---|---|
onResult in the browser | Showing a success screen, advancing your UI | Account state, payments, limits, entitlements |
| A signature-verified webhook | Everything | Nothing |
A clean pattern: onResult polls your own backend, which reports success only once your
webhook handler has run.
Reconciling, not polling
A missed or double-processed webhook can be reconciled by asking the API afterwards:
GET /v1/sessions/{sessionId} and
GET /v1/verifications/{id} both answer with your API key.
That does not make the two things below advisory. Verify signatures and make your handler idempotent: the read is a repair tool for the delivery you lost, not a substitute for the delivery. Reach for it on a schedule or when something is missing, never in a loop while a verification runs, and never as the thing your user's page waits on.