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

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:

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

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 verification events, every delivery is signed. Verify the HMAC over the exact raw bytes of the request body before parsing JSON — re-serializing the parsed body changes whitespace and key order, so the signature won't match. Two headers come with each delivery: X-VerifyAI-Signature (hex HMAC-SHA256 of the raw body) and X-VerifyAI-Timestamp (UNIX seconds, for replay protection).

javascript
const express = require("express");
const crypto = require("node:crypto");
 
const app = express();
const SIGNING_SECRET = process.env.VERIFY_AI_WEBHOOK_SECRET; // whsec_...
const MAX_SKEW_SECONDS = 5 * 60;
 
app.post(
  "/webhooks/verify-ai",
  express.raw({ type: "application/json" }), // capture the RAW bytes
  (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. Safe to parse. Dedupe on event.id, ack fast, work off the request path.
    const event = JSON.parse(req.body.toString("utf8"));
    res.status(200).send("ok");
    if (event.type === "verification.completed") {
      // enqueue(event.data) ...
    }
  },
);
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 timestamps more than a few minutes old as replays.

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.