Webhooks
Webhooks let Verdict Capture push events to your backend the moment something happens — a photo check finishes, an inspection is submitted, someone records a review decision — instead of you polling the API. You add an HTTPS endpoint in the dashboard, subscribe it to the events you care about, and we POST a signed JSON payload to it.
This page is the conceptual model: what events exist, what delivery guarantees you get, and how to trust a payload. For the step-by-step setup (adding an endpoint, verifying signatures in code, a production Node handler), see Setting up webhooks.
Why webhooks
Polling GET /api/v1/verifications/:id
in a loop is laggy and wasteful. Webhooks invert the flow:
- Lower latency. You hear about a result as soon as it's written, not on your next poll tick.
- Less load. No spinning on the API waiting for a status to flip.
- Out-of-band events. Some events — an inspection finished on someone's phone, a reviewer's decision — have no request of yours to poll against. Webhooks are the natural push channel for them.
Event types
Each endpoint is subscribed to a list of events, chosen in Settings → Webhooks:
| Event | Fires when |
|---|---|
verification.completed | A photo check finishes, compliant or not. Includes each photo taken through an inspection link. |
inspection.submitted | An inspection link is submitted and processed, with its outcome, comparison and PDF. |
inspection.reviewed | Someone records a decision (approved or damage_confirmed) on a submitted inspection. |
inspection.decision | A lending inspection gets its automatic decision: condition, eligibility and lending value. |
webhook.test | You click Send test event for an endpoint. Sent to that endpoint only. |
For verification.completed, data is the verification: verdict,
category, reasons, feedback, your metadata, a signed image link, and —
when the policy produces them — damage_findings with
overall_severity, and readings. For inspection.submitted and
inspection.reviewed, data is the full inspection, the same object
GET /api/v1/inspection-sessions/{id} returns. For
inspection.decision, data is the lending decision with its
inspection_id, decision_id and sequence; keep the highest
sequence per inspection, because a correction or a late reference
value sends a newer decision.
inspection.submitted carries the automatic outcome (clear or
review) at the moment the inspection was processed.
inspection.reviewed is a later, human decision on the same
inspection. When both exist, treat the review as the final word.
The envelope
Every delivery has the event name at the top and the payload under
data:
{
"event": "inspection.submitted",
"created_at": "2026-10-08T15:01:12.000Z",
"data": {
"id": "9c2e7b41-0f3a-4d58-8e6b-1a2b3c4d5e6f",
"object": "inspection",
"status": "submitted",
"stage": "pickup",
"reference_id": "LOAN-48213",
"outcome": "review",
"outcome_reasons": ["New damage since delivery: 1 item (Rear of vehicle)"]
}
}| Field | Description |
|---|---|
event | The event type, one your endpoint subscribes to. |
created_at | When the event was generated. Present on inspection.* and webhook.test deliveries; a verification.completed delivery has the verification's own data.created_at instead. |
data | The event payload. |
Three headers travel with every delivery: X-VerifyAI-Event (the event
name), X-VerifyAI-Delivery (a delivery id, stable across retries), and
X-VerifyAI-Signature.
Delivery semantics
Verdict Capture delivers at least once. The practical consequences:
- Duplicates happen. A delivery that doesn't get a
2xxwithin 5 seconds is retried, and you can redeliver one from the dashboard — so the same delivery can arrive more than once. Your handler must be idempotent. - Order is not guaranteed. An
inspection.reviewedcan, in principle, arrive before a retriedinspection.submittedfor the same inspection. Reconcile against the record's current state rather than assuming arrival order. 2xxmeans "received," not "processed." Acknowledge as soon as the event is safely persisted or enqueued, then do heavy work off the request path.
Signatures
Never trust a payload you haven't verified. The signature header looks
like X-VerifyAI-Signature: t=1780000000,v1=<hex>, where v1 is the
HMAC-SHA256, keyed by the endpoint's signing secret, of
`${t}.${rawBody}`. The secret (whsec_…) is shown once, when you
add the endpoint. To verify a delivery:
- Read
tandv1from the header. - Reject it if
tis more than a few minutes old, to defend against replays. - Compute the HMAC over
t, a period, and the exact raw bytes of the body — before any JSON parsing. - Compare it to
v1in constant time (crypto.timingSafeEqual), never===.
The most common webhook bug is parsing the JSON first and signing the re-serialized object. Whitespace and key order differ, so the HMAC never matches. Always compute the signature over the raw bytes you received, then parse.
Retries and idempotency
A failed delivery is retried after 1 minute and again 5 minutes later (three attempts in total), and is then marked failed. Each endpoint's delivery log in the dashboard shows every delivery, its attempts and your endpoint's response, and lets you redeliver a failed one.
Because delivery is at-least-once, deduplicate on
X-VerifyAI-Delivery:
- On receipt, record the delivery id in a table with a unique constraint.
- If the insert conflicts, you've already processed it — return
200and stop. - Run side effects only on the first successful insert.
This makes repeated deliveries harmless, which is exactly the property at-least-once delivery requires.
What's next
- Setting up webhooks — add an endpoint, verify signatures, and ship a hardened Node handler.
- Sending an inspection link —
the flow behind the
inspection.*events. - Verifications — the object inside
verification.completed.