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:

  1. An endpoint subscribed to the events you care about.
  2. A handler that verifies the signature before trusting a payload.
  3. 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.

  1. 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.
  2. 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.
  3. Click Send test event to deliver a signed webhook.test event 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

EventFires when
verification.completedA photo check finishes, compliant or not.
inspection.submittedAn inspection link is submitted and processed: Pickup comparison, PDF and outcome included.
inspection.reviewedSomeone records a decision on a submitted inspection, from the dashboard or the API.
inspection.decisionA 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.

Inspection photos are verifications too

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:

json
{
  "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:

json
{
  "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

HeaderValue
X-VerifyAI-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>. See below.
X-VerifyAI-EventThe event name, the same as event in the body.
X-VerifyAI-DeliveryThe delivery id. Retries and redeliveries of the same delivery reuse it. Use it for idempotency.
Content-Typeapplication/json

3. Verify the signature

Never trust a payload you haven't verified. X-VerifyAI-Signature looks like:

text
t=1780000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

v1 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:

  1. Split the header on , and read t and v1.
  2. Reject it if t is more than a few minutes from your clock. That stops replays.
  3. 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.
  4. Compare it with v1 in 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 200 and stop.
  • Only run side effects on the first successful insert.

Two more contract points:

  • Order is not guaranteed. An inspection.reviewed can arrive before a retried inspection.submitted for the same inspection. Compare the record's submitted_at and review.reviewed_at, or read the current state with GET /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.

Verify before parse, always

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

  1. Add your endpoint in Settings → Webhooks and click Send test event. The delivery log shows the status and body you returned.
  2. While iterating locally, tunnel a public HTTPS URL to your dev server (ngrok, Cloudflare Tunnel) and add it as a throwaway endpoint.
  3. 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

What do you need to verify?

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

Discuss your workflow