Setting up webhooks

Polling for verification results doesn't scale and feels laggy. Webhooks flip it around: you register an HTTPS endpoint, and we POST you an event the moment a verification finishes, an inspection session is submitted, or a manual review changes a verdict. This guide takes you from "no endpoint" to a hardened handler that verifies signatures, deduplicates retries, and acknowledges fast.

By the end you'll have:

  1. A registered endpoint subscribed to the events you care about.
  2. A handler that verifies the HMAC signature before trusting a payload.
  3. Idempotent processing that survives our automatic retries.

1. Register an endpoint

Webhook endpoints are configured per customer account. Register a URL and the list of events you want delivered there:

bash
curl -X POST https://verify.switchlabs.dev/api/v1/webhooks \
  -H "X-API-Key: vai_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.yourcompany.com/webhooks/verify-ai",
    "events": [
      "verification.completed",
      "verification.reviewed",
      "inspection.submitted"
    ]
  }'

The response includes the endpoint's id and its signing secret — the only time the plaintext secret is returned. Store it somewhere your handler can read it (a secret manager, not source control):

json
{
  "id": "whk_3kf9a2",
  "url": "https://api.yourcompany.com/webhooks/verify-ai",
  "events": ["verification.completed", "verification.reviewed", "inspection.submitted"],
  "signing_secret": "whsec_8mZ4...do_not_log_this",
  "status": "active",
  "created_at": "2026-06-01T12:00:00Z"
}

Events you can subscribe to

| Event | Fires when | | ------------------------ | ----------------------------------------------------------------------- | | verification.completed | A /v1/verify call finishes processing (compliant or not). | | verification.reviewed | A human review changes a verification's verdict in the dashboard. | | inspection.submitted | A self-inspection session is finalized by the recipient. |

HTTPS only, and respond fast

Endpoints must be HTTPS. Your handler should do the minimum inline — verify the signature, enqueue the work, return 2xx — and offload anything slow (PDF rendering, DB fan-out, downstream calls) to a background job. We treat a slow response the same as a failure and will retry it.

2. The payload shape

Every delivery is a JSON body with a stable envelope. The data object for a verification.* event is the same Verification object you get back from the API:

json
{
  "id": "evt_9Q2v...",
  "type": "verification.completed",
  "created_at": "2026-06-01T12:00:03Z",
  "data": {
    "id": "ver_8x92m4k9",
    "status": "success",
    "is_compliant": false,
    "confidence": 0.94,
    "policy": "pol_forest1",
    "category": "bad_parking",
    "violation_reasons": ["not_on_tactile_paving", "not_blocking_pedestrian"],
    "feedback": "Please move your bike off the tactile paving and away from the pedestrian path.",
    "metadata": { "trip_id": "trip_123", "user_id": "usr_456" },
    "evaluation_source": "cloud_vlm"
  }
}

Top-level envelope fields:

| Field | Description | | ------------ | ------------------------------------------------------------------------ | | id | Unique event ID, prefix evt_. Use this for idempotency. | | type | The event type, matching one of your subscribed events. | | created_at | ISO 8601 timestamp of when the event was generated. | | data | The event payload. For verification.* this is the Verification object. |

For inspection.submitted, data carries session_id, customer_id, and the captured shot summary rather than a single verification — see Sending an inspection link.

3. Verify the signature

Never trust a payload you haven't verified. Every delivery carries two headers:

| Header | Description | | ----------------------- | --------------------------------------------------------------------- | | X-VerifyAI-Signature | Hex HMAC-SHA256 of the raw request body, keyed by your signing secret. | | X-VerifyAI-Timestamp | UNIX seconds when we sent the delivery. Use it to reject stale replays. |

The signature is computed over the exact raw bytes of the request body, so verify it before any JSON parsing — re-serializing the parsed body will not reproduce the same bytes and the check will fail. Two rules that matter:

  • Use a constant-time comparison (crypto.timingSafeEqual), not ===. A plain string compare leaks timing information.
  • Reject stale timestamps. If X-VerifyAI-Timestamp is more than a few minutes old, treat it as a replay and drop it.

4. Retries and idempotency

We retry any delivery that doesn't get a 2xx response, with exponential backoff over roughly 24 hours. That guarantees at-least-once delivery — which means you will occasionally receive the same event twice, and your handler must tolerate it.

Deduplicate on the envelope id (the evt_...):

  • On receipt, record the evt_ id in a table with a unique constraint.
  • If the insert conflicts, you've already processed it — return 200 and do nothing else.
  • Only run side effects on the first successful insert.

Two more contract points worth knowing:

  • Order is not guaranteed. A verification.reviewed event can in principle arrive before you've finished processing the original verification.completed. Make handlers commutative where you can, or reconcile against the verification's current state via the API.
  • 2xx means "I've got it," not "I'm done." Acknowledge as soon as the event is safely persisted/enqueued. If you do the heavy work inline and it times out, we retry and you double-process.

5. A Node handler

Here's a complete Express handler that verifies the signature on the raw body, rejects stale timestamps, deduplicates on the event id, and acknowledges fast.

const express = require("express");
const crypto = require("crypto");

const app = express();
const SIGNING_SECRET = process.env.VERIFY_AI_WEBHOOK_SECRET; // whsec_...
const MAX_SKEW_SECONDS = 5 * 60;

// IMPORTANT: capture the RAW body — the signature is over exact bytes.
app.post(
"/webhooks/verify-ai",
express.raw({ type: "application/json" }),
async (req, res) => {
  const signature = req.header("X-VerifyAI-Signature") || "";
  const timestamp = req.header("X-VerifyAI-Timestamp") || "";

  // 1. Reject stale deliveries (replay protection).
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!timestamp || age > MAX_SKEW_SECONDS) {
    return res.status(400).send("stale or missing timestamp");
  }

  // 2. Verify the HMAC over the raw body, constant-time.
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(req.body) // req.body is a Buffer here
    .digest("hex");

  const sigBuf = Buffer.from(signature, "hex");
  const expBuf = Buffer.from(expected, "hex");
  if (
    sigBuf.length !== expBuf.length ||
    !crypto.timingSafeEqual(sigBuf, expBuf)
  ) {
    return res.status(401).send("bad signature");
  }

  // 3. Now it's safe to parse.
  const event = JSON.parse(req.body.toString("utf8"));

  // 4. Idempotency: insert evt_ id; conflict means already processed.
  const firstTime = await recordEventOnce(event.id);
  if (!firstTime) {
    return res.status(200).send("duplicate, ignored");
  }

  // 5. Acknowledge fast, do the work off the request path.
  res.status(200).send("ok");

  switch (event.type) {
    case "verification.completed":
      await enqueue("verify.completed", event.data);
      break;
    case "verification.reviewed":
      await enqueue("verify.reviewed", event.data);
      break;
    case "inspection.submitted":
      await enqueue("inspection.submitted", event.data);
      break;
    default:
      // Unknown type — we already 200'd, so just log it.
      console.warn("unhandled verify-ai event", event.type);
  }
}
);

app.listen(3000);

The two handlers do the same thing in the same order: grab the raw body, reject stale timestamps, verify the HMAC in constant time, parse, dedupe on event.id, then enqueue. The recordEventOnce and enqueue helpers are yours — back the first with a unique constraint on the event id and the second with whatever queue you already run.

Verify before parse, always

The single 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

Trigger a real event by running a verification against any policy with your endpoint registered, then inspect what landed. While you're iterating locally, tunnel a public HTTPS URL to your dev server (ngrok, Cloudflare Tunnel) and register that as a throwaway endpoint. Watch for:

  • A 2xx response within your handler's budget.
  • The signature check passing on the raw body.
  • The same evt_ id, delivered twice on a forced retry, processed once.

When you're confident, point the endpoint at production and add the other event types.

What's next

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.