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.

Keep your key on the server

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 URLhttps://verify.switchlabs.dev/api
AuthX-API-Key: vai_… header on every request
Contentmultipart/form-data or application/json
Image limit10 MB, JPEG / PNG / WebP
RuntimeNode 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:

javascript
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.

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

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

StatusAction
400Fix the request — missing field, image too large, or unknown policy ID.
401Check the X-API-Key header.
403Key isn't valid for VerifyAI, or no active subscription.
429Back off — read Retry-After (seconds) and retry after it.
5xxTransient — 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:

javascript
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);
Verify before parse, always

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

Get in Touch

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