JavaScript / TypeScript
There's no separate JavaScript package to install — VerifyAI is a plain
HTTP/JSON API, and Node 18+ ships fetch and FormData built in. This
page shows the idiomatic server-side pattern: read your API key from the
environment, POST a photo to /v1/verify, and branch on a typed
response. For the in-app camera scanner with the capture / retry loop,
use the React Native SDK instead — this page
is for backend code.
The X-API-Key header authenticates against your subscription, so
anyone holding the key can spend your quota. Run these calls from a
backend and read the key from process.env — never ship it in
browser-side JavaScript. See Authentication
for key format and rotation.
The essentials
| Base URL | https://verify.switchlabs.dev/api |
| Auth | X-API-Key: vai_… header on every request |
| Content | multipart/form-data or application/json |
| Image limit | 10 MB, JPEG / PNG / WebP |
| Runtime | Node 18+ (built-in fetch/FormData), or any fetch polyfill |
Initialize
There's no client object to construct — just centralize the base URL and key so every call shares them. Read the key from the environment at startup and fail fast if it's missing:
const VERIFY_AI_KEY = process.env.VERIFY_AI_KEY;
if (!VERIFY_AI_KEY) throw new Error("VERIFY_AI_KEY is not set");
const VERIFY_AI_BASE = "https://verify.switchlabs.dev/api";Verify a photo (multipart)
Multipart is the most direct way to send a file from a Node backend. Two
fields are required: image and policy. Add a metadata field (a
JSON string) to persist context like a trip or user ID alongside the
verification.
import fs from "node:fs";
const VERIFY_AI_KEY = process.env.VERIFY_AI_KEY;
const VERIFY_AI_BASE = "https://verify.switchlabs.dev/api";
const form = new FormData();
form.set("image", new Blob([fs.readFileSync("scooter.jpg")]), "scooter.jpg");
form.set("policy", "scooter_parking");
form.set("metadata", JSON.stringify({ user_id: "usr_456", trip_id: "trip_123" }));
const res = await fetch(`${VERIFY_AI_BASE}/v1/verify`, {
method: "POST",
headers: { "X-API-Key": VERIFY_AI_KEY },
body: form,
});
if (!res.ok) {
const { error } = await res.json().catch(() => ({}));
throw new Error(`verify failed: ${res.status} ${error ?? ""}`);
}
const result = await res.json();
console.log(result.is_compliant, result.category, result.violation_reasons);Verify a photo (JSON + base64)
When you already hold the image as a base64 string — say it arrived in a
JSON request from a mobile client — POST application/json instead of a
file. The image field accepts a raw base64 string or a full data URL;
the data:image/...;base64, prefix is stripped automatically.
const res = await fetch(`${VERIFY_AI_BASE}/v1/verify`, {
method: "POST",
headers: {
"X-API-Key": VERIFY_AI_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
image: base64Image, // raw base64 or a data URL
policy: "scooter_parking",
metadata: { device_id: "dev_123" },
}),
});
const result = await res.json();The response
A 200 OK is a Verification object. The
fields you'll branch on most:
{
"id": "ver_8x92m4k9",
"created_at": "2026-06-01T14:30:00Z",
"status": "success",
"is_compliant": false,
"confidence": 0.94,
"policy": "scooter_parking",
"category": "unsafe",
"violation_reasons": ["blocking_sidewalk", "kickstand_up"],
"feedback": "Please deploy the kickstand and move away from the walkway.",
"image_url": "https://...signed-url...",
"evaluation_source": "cloud_vlm"
}is_compliant is the top-level pass/fail; category is the outcome
bucket the policy resolved to; violation_reasons lists the failed
criterion IDs; and feedback is a human-readable string safe to surface
to an end user. See the Verify endpoint
reference for every field.
Handling errors and rate limits
Non-200 responses carry a JSON { "error": "..." } body. Branch your
retry logic on the status code — only transient failures should be
retried, and a 429 tells you exactly how long to wait via the
Retry-After header:
| Status | Action |
|---|---|
| 400 | Fix the request — missing field, image too large, or unknown policy ID. |
| 401 | Check the X-API-Key header. |
| 403 | Key isn't valid for VerifyAI, or no active subscription. |
| 429 | Back off — read Retry-After (seconds) and retry after it. |
| 5xx | Transient — retry with backoff, reusing your Idempotency-Key. |
Every response carries an X-Request-Id header. Log it on failures —
it's the fastest way for support to trace a specific call.
Retries with idempotency
Network drops and client retries happen. Send an Idempotency-Key
header so a retried request returns the original cached response instead
of re-running (and re-billing) the verification. Pair it with backoff
that only retries 429 and 5xx:
import { randomUUID } from "node:crypto";
async function verifyWithRetry(form, { maxAttempts = 4 } = {}) {
const idempotencyKey = randomUUID(); // stable across this request's retries
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(`${VERIFY_AI_BASE}/v1/verify`, {
method: "POST",
headers: {
"X-API-Key": VERIFY_AI_KEY,
"Idempotency-Key": idempotencyKey,
},
body: form,
});
if (res.ok) return res.json();
// Only retry transient failures.
if (res.status === 429 || res.status >= 500) {
const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
continue;
}
// 4xx (other than 429) won't succeed on retry — fail fast.
const { error } = await res.json().catch(() => ({}));
throw new Error(`verify failed: ${res.status} ${error ?? ""}`);
}
throw new Error("verify failed after retries");
}Keys are scoped per route. Reusing a key with a different request body
returns 422 Unprocessable Entity, so generate a fresh key per logical
request and reuse it only across that request's retries.
Verify webhook signatures
If you subscribe to webhook events, every delivery is signed. The
X-VerifyAI-Signature header looks like t=1780000000,v1=<hex>: t is
the send time in UNIX seconds and v1 is the hex HMAC-SHA256, keyed by
your endpoint's signing secret, of t, a period, and the exact raw
bytes of the request body. Verify it before parsing JSON:
re-serializing the parsed body changes whitespace and key order, so the
signature won't match. The body's event field names the event, and
X-VerifyAI-Delivery is the same on every retry of a delivery, so
deduplicate on it.
These receivers are the ones our test suite runs against real signed deliveries:
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);Always compute the HMAC over the raw bytes you received, before any
JSON parsing. Use crypto.timingSafeEqual rather than === so the
comparison is constant-time, and reject a t more than five minutes
from your clock as a replay.
The Setting up webhooks guide covers endpoint registration, the full payload envelope, and idempotent processing of at-least-once retries.
What's next
- API reference: Verify — every request parameter, response field, and error code.
- REST (curl) — the same calls from a shell and other languages.
- Setting up webhooks — register an endpoint and process events end to end.
- Authentication — key format, header, and rotation.