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:

EventFires when
verification.completedA photo check finishes, compliant or not. Includes each photo taken through an inspection link.
inspection.submittedAn inspection link is submitted and processed, with its outcome, comparison and PDF.
inspection.reviewedSomeone records a decision (approved or damage_confirmed) on a submitted inspection.
inspection.decisionA lending inspection gets its automatic decision: condition, eligibility and lending value.
webhook.testYou 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.

submitted vs. reviewed

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:

json
{
  "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)"]
  }
}
FieldDescription
eventThe event type, one your endpoint subscribes to.
created_atWhen the event was generated. Present on inspection.* and webhook.test deliveries; a verification.completed delivery has the verification's own data.created_at instead.
dataThe 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 2xx within 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.reviewed can, in principle, arrive before a retried inspection.submitted for the same inspection. Reconcile against the record's current state rather than assuming arrival order.
  • 2xx means "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:

  1. Read t and v1 from the header.
  2. Reject it if t is more than a few minutes old, to defend against replays.
  3. Compute the HMAC over t, a period, and the exact raw bytes of the body — before any JSON parsing.
  4. Compare it to v1 in constant time (crypto.timingSafeEqual), never ===.
Verify before you parse

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 200 and 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

What do you need to verify?

Tell us about the evidence, the decision and the workflow around it.

Discuss your workflow