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:
- A registered endpoint subscribed to the events you care about.
- A handler that verifies the HMAC signature before trusting a payload.
- 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:
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):
{
"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. |
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:
{
"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-Timestampis 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.reviewedevent can in principle arrive before you've finished processing the originalverification.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.
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
- Sending an inspection link — the
flow behind the
inspection.submittedevent. - Concepts: Verifications — the object
shape inside every
verification.*payload. - Handling retries — what the SDK does before a verification ever reaches your webhook.