Python

There's no separate Python package to install — VerifyAI is a plain HTTP/JSON API, so any HTTP client works. This page uses requests for the synchronous path and httpx when you need async. Read your API key from the environment, POST a photo to /v1/verify, and branch on the structured response. This is backend-only code; for an in-app camera scanner, use the React Native or Flutter SDK instead.

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 service and read the key from the environment. See Authentication for key format and rotation.

Install

bash
pip install requests   # synchronous
pip install httpx      # async (optional)

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

Initialize

There's no client object to construct — just centralize the base URL and key. Read the key from the environment at startup so it never lives in source control:

python
import os
 
VERIFY_AI_KEY = os.environ["VERIFY_AI_KEY"]
VERIFY_AI_BASE = "https://verify.switchlabs.dev/api"

Verify a photo

Multipart is the most direct way to send a file. Two fields are required: image and policy. Pass metadata as a JSON string to persist context like a trip or user ID alongside the verification.

import os, json, requests

VERIFY_AI_KEY = os.environ["VERIFY_AI_KEY"]
VERIFY_AI_BASE = "https://verify.switchlabs.dev/api"

with open("scooter.jpg", "rb") as f:
  res = requests.post(
      f"{VERIFY_AI_BASE}/v1/verify",
      headers={"X-API-Key": VERIFY_AI_KEY},
      files={"image": f},
      data={
          "policy": "scooter_parking",
          "metadata": json.dumps({"user_id": "usr_456", "trip_id": "trip_123"}),
      },
      timeout=30,
  )

res.raise_for_status()
result = res.json()
print(result["is_compliant"], result["category"], result["violation_reasons"])

JSON + base64

When you already hold the image as a base64 string, 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.

python
import os, base64, requests
 
with open("scooter.jpg", "rb") as f:
    b64 = base64.b64encode(f.read()).decode()
 
res = requests.post(
    f"{VERIFY_AI_BASE}/v1/verify",
    headers={
        "X-API-Key": VERIFY_AI_KEY,
        "Content-Type": "application/json",
    },
    json={
        "image": b64,
        "policy": "scooter_parking",
        "metadata": {"device_id": "dev_123"},
    },
    timeout=30,
)
result = 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:

python
if result["is_compliant"]:
    end_ride()
else:
    notify_rider(result["feedback"], result["violation_reasons"])

See the Verify endpoint reference for every field.

Handling errors and rate limits

Non-200 responses carry a JSON {"error": "..."} body. raise_for_status() turns any 4xx/5xx into an HTTPError, but you'll usually want to branch on the status code so you only retry transient failures — 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.
python
if res.status_code != 200:
    body = res.json() if res.headers.get("content-type", "").startswith("application/json") else {}
    request_id = res.headers.get("X-Request-Id")
    raise RuntimeError(f"verify failed {res.status_code}: {body.get('error')} (req {request_id})")

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

Send an Idempotency-Key header so a retried request returns the original cached response instead of re-running (and re-billing) the verification. Keep the same key across one request's retries, and only retry 429 and 5xx:

python
import os, time, uuid, requests
 
def verify_with_retry(path: str, policy: str, max_attempts: int = 4) -> dict:
    idempotency_key = str(uuid.uuid4())  # stable across this request's retries
 
    for attempt in range(1, max_attempts + 1):
        with open(path, "rb") as f:
            res = requests.post(
                f"{VERIFY_AI_BASE}/v1/verify",
                headers={
                    "X-API-Key": VERIFY_AI_KEY,
                    "Idempotency-Key": idempotency_key,
                },
                files={"image": f},
                data={"policy": policy},
                timeout=30,
            )
 
        if res.status_code == 200:
            return res.json()
 
        # Only retry transient failures.
        if res.status_code == 429 or res.status_code >= 500:
            retry_after = int(res.headers.get("Retry-After") or 2 ** attempt)
            time.sleep(retry_after)
            continue
 
        # 4xx (other than 429) won't succeed on retry — fail fast.
        res.raise_for_status()
 
    raise RuntimeError("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, with hmac.compare_digest so the comparison is constant-time. 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.

This Flask receiver is the one our test suite runs against real signed deliveries:

import hashlib
import hmac
import json
import os
import time
import psycopg  # pip install "psycopg[binary]"

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

from flask import Flask, abort, request

app = Flask(__name__)
SIGNING_SECRET = os.environ["VERIFY_AI_WEBHOOK_SECRET"]  # whsec_...


def verify_verifyai_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    """X-VerifyAI-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of b"<t>." + raw body>."""
    parts = dict(
        part.strip().split("=", 1) for part in (signature_header or "").split(",") if "=" in part
    )
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not (t.isascii() and t.isdigit()) or len(v1) != 64:
        return False
    # A timestamp more than five minutes from now is a replay: reject it.
    if abs(time.time() - int(t)) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.lower().encode())


@app.post("/webhooks/verifyai")
def verifyai_webhook():
    raw_body = request.get_data()  # the exact bytes that were signed; don't parse first
    if not verify_verifyai_signature(raw_body, request.headers.get("X-VerifyAI-Signature", ""), SIGNING_SECRET):
        abort(401)
    delivery_id = request.headers.get("X-VerifyAI-Delivery")
    if not delivery_id:
        abort(400)
    try:
        payload = json.loads(raw_body)
    except ValueError:
        abort(400)
    event, data = payload["event"], payload["data"]
    if event == "webhook.test":
        return "", 200
    try:
        # The connection context commits before returning 200; errors roll back.
        with psycopg.connect(os.environ["DATABASE_URL"]) as db:
            db.execute("INSERT INTO verifyai_webhook_inbox (delivery_id, event, payload) VALUES (%s, %s, %s::jsonb) ON CONFLICT (delivery_id) DO NOTHING", (delivery_id, event, json.dumps(data)))
    except psycopg.Error:
        abort(503)  # no acknowledged delivery without a durable work obligation
    return "", 200
Verify before parse, always

Read request.get_data() for the raw bytes — never request.json/request.get_json(), which re-parses the body so the HMAC will never match. In Django, use request.body in a csrf_exempt view. 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.