Setting up webhooks
Polling for results doesn't scale and feels laggy. Webhooks flip it around: you add an HTTPS endpoint, and we POST you an event the moment a photo check finishes, an inspection is submitted, or someone records a review decision. This guide takes you from "no endpoint" to a hardened handler that verifies signatures, tolerates retries, and acknowledges fast.
By the end you'll have:
- An endpoint subscribed to the events you care about.
- A handler that verifies the signature before trusting a payload.
- Idempotent processing that survives our automatic retries.
1. Add an endpoint
Endpoints are managed in the dashboard, under Settings → Webhooks. There is no public API for creating or deleting them.
- Under Add an endpoint, choose its events, enter an HTTPS URL, and click Add. URLs must be HTTPS and must not point at a private or loopback address.
- Copy the signing secret (
whsec_…). It is shown once. Store it where your handler can read it, such as a secret manager, not source control. Lost it? Delete the endpoint and add it again. - Click Send test event to deliver a signed
webhook.testevent and see the response your endpoint returned.
No server yet? Click Use the VerifyAI test receiver on the same screen. It accepts every delivery and checks the signature exactly as your endpoint should, and the delivery log shows whether it verified, so you can see real, signed deliveries before writing any code.
Each endpoint's delivery log shows its recent deliveries: event, status, attempts, and the HTTP status and body your endpoint returned. A failed or retrying delivery has a Redeliver button that tries it again immediately. You can change an endpoint's events at any time.
Events you can subscribe to
| Event | Fires when |
|---|---|
verification.completed | A photo check finishes, compliant or not. |
inspection.submitted | An inspection link is submitted and processed: Pickup comparison, PDF and outcome included. |
inspection.reviewed | Someone records a decision on a submitted inspection, from the dashboard or the API. |
inspection.decision | A lending inspection gets its automatic decision: condition, eligibility and lending value. |
webhook.test is sent only when you click Send test event, to that
one endpoint.
Each photo taken through an inspection link is checked as a
verification, so an endpoint subscribed to verification.completed
receives one event per photo, with metadata.inspection_session_id
and metadata.inspection_slot set. If you only care about the
finished inspection, subscribe that endpoint to
inspection.submitted alone.
2. The payload shape
Every delivery is a JSON body with the event name and its payload under
data. A verification.completed delivery:
{
"event": "verification.completed",
"data": {
"id": "ver_8x92m4k9",
"created_at": "2026-06-01T12:00:03Z",
"status": "success",
"is_compliant": false,
"confidence": 0.94,
"policy": "pol_forest1",
"category": "bad_parking",
"violation_reasons": ["not_on_tactile_paving"],
"feedback": "Please move your bike off the tactile paving.",
"warning_violation_reasons": [],
"warning_feedback": null,
"metadata": { "trip_id": "trip_123", "user_id": "usr_456" },
"image_url": "<signed URL, valid for 1 hour>"
}
}When the policy reports damage, data also carries damage_findings
and overall_severity. When it reads values off the photo (an
odometer, for example), data carries readings.
inspection.submitted, inspection.reviewed, inspection.decision and
webhook.test deliveries add a created_at for the event itself:
{
"event": "inspection.reviewed",
"created_at": "2026-10-08T16:02:11.000Z",
"data": { "id": "9c2e7b41-0f3a-4d58-8e6b-1a2b3c4d5e6f", "object": "inspection", "...": "..." }
}For the inspection events, data is the full inspection, the same
object GET /api/v1/inspection-sessions/{id} returns. See
Sending an inspection link
for a complete example. For inspection.decision, data is the
lending decision with its inspection_id, decision_id and sequence;
a later correction or reference value sends a new decision with a higher
sequence, so keep the highest one per inspection (see
Vehicle inspection integration).
For webhook.test, data is
{ "message": "Test event from VerifyAI", "webhook_id": "…" }.
Headers
| Header | Value |
|---|---|
X-VerifyAI-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. See below. |
X-VerifyAI-Event | The event name, the same as event in the body. |
X-VerifyAI-Delivery | The delivery id. Retries and redeliveries of the same delivery reuse it. Use it for idempotency. |
Content-Type | application/json |
3. Verify the signature
Never trust a payload you haven't verified. X-VerifyAI-Signature
looks like:
t=1780000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 is the hex HMAC-SHA256, keyed by your signing secret, of the
string `${t}.${rawBody}`: the timestamp, a period, and the
exact raw bytes of the request body. To check a delivery:
- Split the header on
,and readtandv1. - Reject it if
tis more than a few minutes from your clock. That stops replays. - Compute
HMAC-SHA256(secret, t + "." + rawBody)over the raw body, before any JSON parsing. Re-serializing the parsed body won't reproduce the same bytes. - Compare it with
v1in constant time (crypto.timingSafeEqual), never with===.
t is set when the attempt is sent, so every retry carries a fresh
timestamp and signature.
4. Retries and idempotency
A delivery succeeds when your endpoint answers with any 2xx within 5
seconds. Anything else (a non-2xx status, a timeout, a connection
error) is retried after 1 minute and again 5 minutes later (three
attempts in total); then it is marked failed. Retries run on a
schedule, so one can arrive a little after it is due. You can always
Redeliver a failed delivery from the dashboard.
That gives you at-least-once delivery, which means you will
occasionally receive the same delivery twice. 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. - Only run side effects on the first successful insert.
Two more contract points:
- Order is not guaranteed. An
inspection.reviewedcan arrive before a retriedinspection.submittedfor the same inspection. Compare the record'ssubmitted_atandreview.reviewed_at, or read the current state withGET /api/v1/inspection-sessions/{id}. - 2xx means "I've got it," not "I'm done." Acknowledge as soon as the event is safely stored or enqueued. Slow inline work risks the 5-second timeout, which we treat as a failure and retry.
5. A complete handler
A complete handler verifies the signature on the raw body, rejects
stale timestamps, deduplicates on the delivery id, routes on the
body's event, and acknowledges fast. Our test suite runs each of
these against real signed deliveries, including tampered, re-signed
and stale ones.
const crypto = require("node:crypto");
const express = require("express");
const { Pool } = require("pg");
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
const SIGNING_SECRET = process.env.VERIFY_AI_WEBHOOK_SECRET; // whsec_...
// Create this PostgreSQL table before starting the receiver:
// CREATE TABLE verifyai_webhook_inbox (
// delivery_id text PRIMARY KEY, event text NOT NULL, payload jsonb NOT NULL,
// received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz
// );
// A worker polls rows WHERE processed_at IS NULL and marks them only after
// successful processing. Retry failed work; make business effects idempotent.
// If forwarding to another queue, mark processed only after durable enqueue.
// Never store a duplicate receipt separately from the work obligation.
// X-VerifyAI-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
function verifyVerifyAISignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
if (typeof rawBody !== "string" && !Buffer.isBuffer(rawBody)) return false;
let t = "";
let v1 = "";
for (const part of String(signatureHeader || "").split(",")) {
const [key, value = ""] = part.trim().split("=");
if (key === "t") t = value;
if (key === "v1") v1 = value;
}
if (!/^[0-9]+$/.test(t) || !/^[0-9a-f]{64}$/i.test(v1)) return false;
// A timestamp more than five minutes from now is a replay: reject it.
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + ".")
.update(rawBody) // the exact bytes received, never re-serialized JSON
.digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, "hex"));
}
app.post("/webhooks/verifyai", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyVerifyAISignature(req.body, req.get("X-VerifyAI-Signature"), SIGNING_SECRET)) {
return res.status(401).send("invalid signature");
}
const deliveryId = req.get("X-VerifyAI-Delivery");
if (!deliveryId) return res.status(400).send("missing delivery id");
let payload;
try { payload = JSON.parse(req.body.toString("utf8")); }
catch { return res.status(400).send("invalid JSON"); }
const { event, data } = payload;
if (event === "webhook.test") return res.status(200).send("ok");
try {
// verification.completed, inspection.submitted, inspection.reviewed,
// inspection.decision (keep the highest data.sequence per inspection_id).
await db.query("INSERT INTO verifyai_webhook_inbox (delivery_id, event, payload) VALUES ($1, $2, $3::jsonb) ON CONFLICT (delivery_id) DO NOTHING", [deliveryId, event, JSON.stringify(data)]);
return res.status(200).send("ok"); // committed or already durably recorded
} catch {
return res.status(503).send("inbox unavailable"); // Verify will retry
}
});
app.listen(3000);The handlers do the same thing in the same order: read the raw body,
verify the timestamp and HMAC, dedupe on the delivery id, parse, then
enqueue. recordDeliveryOnce and enqueue (record_delivery_once and
enqueue in Python and Ruby) are yours: back the first with a unique
constraint on the delivery id and the second with whatever queue you
already run.
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 — or worse, a framework silently consumes the body and your handler trusts an unverified payload. Always compute the HMAC over the exact raw bytes you received, before any parsing.
6. Test it
- Add your endpoint in Settings → Webhooks and click Send test event. The delivery log shows the status and body you returned.
- While iterating locally, tunnel a public HTTPS URL to your dev server (ngrok, Cloudflare Tunnel) and add it as a throwaway endpoint.
- Run a real verification, or submit a test inspection link, and watch the event arrive.
Check for:
- A 2xx response well inside 5 seconds.
- The signature check passing on the raw body.
- The same delivery, redelivered from the log, processed once.
What's next
- Sending an inspection link —
the flow behind
inspection.submittedandinspection.reviewed. - Concepts: Verifications — the
fields inside
verification.completed. - Handling retries — what the SDK does before a verification ever reaches your webhook.