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.
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
pip install requests # synchronous
pip install httpx # async (optional)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 |
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:
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.
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:
{
"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:
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:
| 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. |
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:
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 "", 200Read 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
- 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.