Authentication
Every request needs an Authorization: Bearer sk_live_... header. Create keys under Settings → API keys in the console. The full key is shown once at creation - only its SHA-256 hash is stored server-side.
Keys are scoped to a single organization. Revoking a key takes effect on the next request.
Submit a scan
/api/public/v1/scansPre-sample the video on your side into 1-12 evenly spaced JPEG frames (recommended 6-8) and submit them as base64 alongside a millisecond timestamp. Frames are analyzed synchronously; the response contains the final verdict, per-signal scores, and rate-limit headers.
Request body
| filename | string (optional) | ≤ 300 chars | Original filename. Surfaced in the analyst console for triage. |
| external_ref | string (optional) | ≤ 200 chars | Your reference (KYC session id, case number). Idempotency is NOT enforced - resubmitting the same ref creates a second scan. |
| frames | array (required) | 1-16 items | Ordered JPEG frames. 12+ recommended for full micro-expression coverage. |
| frames[].ts_ms | integer (required) | ≥ 0 | Frame timestamp within the source video, in milliseconds. |
| frames[].image_base64 | string (required) | 100 - 3.5M chars | Base64-encoded JPEG. No data: prefix. Recommended 512-1024 px longest side. |
| audio_base64 | string (optional) | ≤ 2M chars | Base64-encoded audio (WAV 16 kHz mono preferred; mp3/m4a/ogg/webm also accepted). Unlocks voice & linguistic forensics + real lip-sync alignment. |
| audio_mime | string (optional) | ≤ 60 chars | MIME type of the audio (e.g. "audio/wav"). Required when audio_base64 is set. |
Example request
curl -X POST https://api.verifai.example/api/public/v1/scans \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"filename": "kyc-selfie-8123.mp4",
"external_ref": "kyc-session-8123",
"frames": [
{ "ts_ms": 0, "image_base64": "/9j/4AAQSkZJRgAB..." },
{ "ts_ms": 500, "image_base64": "/9j/4AAQSkZJRgAB..." },
{ "ts_ms": 1000, "image_base64": "/9j/4AAQSkZJRgAB..." },
{ "ts_ms": 1500, "image_base64": "/9j/4AAQSkZJRgAB..." },
{ "ts_ms": 2000, "image_base64": "/9j/4AAQSkZJRgAB..." },
{ "ts_ms": 2500, "image_base64": "/9j/4AAQSkZJRgAB..." }
]
}'Response - 200 OK
HTTP/1.1 200 OK
content-type: application/json
x-ratelimit-limit-minute: 30
x-ratelimit-remaining-minute: 29
x-ratelimit-limit-month: 10000
x-ratelimit-remaining-month: 9873
{
"scan_id": "b41a9c8f-2d1e-4c3b-9a77-1e0f2f4d0e01",
"verdict": "likely_synthetic",
"score": 0.82,
"confidence": 0.91,
"signals": [
{ "signal": "frame_artifacts", "score": 0.88, "confidence": 0.92, "weight": 0.22 },
{ "signal": "temporal_consistency", "score": 0.74, "confidence": 0.80, "weight": 0.14 },
{ "signal": "cardiac_liveness", "score": 0.90, "confidence": 0.78, "weight": 0.17 },
{ "signal": "physics_consistency", "score": 0.71, "confidence": 0.75, "weight": 0.17 },
{ "signal": "provenance", "score": 0.95, "confidence": 0.99, "weight": 0.10 }
]
}Response fields
| scan_id | uuid | - | Stable id. Use for GET, PDF report, and signed receipt. |
| verdict | enum | "authentic" | "suspicious" | "likely_synthetic" | "inconclusive" | Final calibrated verdict. See below. |
| score | number | 0 - 1 | Weighted ensemble suspicion score. |
| confidence | number | 0 - 1 | Aggregate confidence across signals. Below 0.35 forces verdict to inconclusive. |
| signals[] | array | - | Per-signal breakdown used to compute the verdict. |
| signals[].signal | string | - | Signal id. See Signal glossary. |
| signals[].score | number | 0 - 1 | Suspicion contribution of this signal (higher = more likely synthetic). |
| signals[].confidence | number | 0 - 1 | How much weight this signal deserves for this specific clip. |
| signals[].weight | number | 0 - 1 | Ensemble weight for this signal. |
Special case - known-synthetic short-circuit
If the video's perceptual fingerprint matches an entry in the cross-org threat-intel registry, the full pipeline is skipped and a single known_synthetic signal is returned. The verdict is locked at likely_synthetic.
HTTP/1.1 200 OK
{
"scan_id": "b41a9c8f-...",
"verdict": "likely_synthetic",
"score": 1,
"confidence": 0.99,
"signals": [
{
"signal": "known_synthetic",
"score": 1,
"confidence": 0.99,
"weight": 1
}
]
}Fetch an existing scan
/api/public/v1/scans/:idRead a previously submitted scan, including any analyst override applied in the console.
curl https://api.verifai.example/api/public/v1/scans/b41a9c8f-... \ -H "Authorization: Bearer sk_live_..."
Returns the same shape as the POST response, plus a reviewed boolean and (if applicable) a reviewed_verdict. When present, reviewed_verdict is authoritative - the automated verdict is preserved for auditability under automated_verdict.
Signed verdict receipt
/api/public/v1/scans/:id/receiptReturns a compact JWS signed with the VerifAI Ed25519 key. Anyone can verify it offline against the public key at /api/public/v1/pubkey - no network call to VerifAI is required to trust a receipt. The payload includes the current head of your organization's audit chain, binding the verdict to the tamper-evident log.
curl https://api.verifai.example/api/public/v1/scans/b41a9c8f-.../receipt \ -H "Authorization: Bearer sk_live_..."
{
"receipt": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ2ZXJpZmFpIiw...sig",
"payload": {
"iss": "verifai",
"typ": "verdict-receipt",
"scan_id": "b41a9c8f-...",
"org_id": "3fa9-...",
"verdict": "likely_synthetic",
"automated_verdict": "likely_synthetic",
"reviewed": false,
"score": 0.82,
"confidence": 0.91,
"completed_at": "2026-07-25T14:03:11.842Z",
"external_ref": "kyc-session-8123",
"audit_chain_head": "9f2c4b71a3e8...",
"audit_chain_entries": 1284,
"issued_at": "2026-07-25T14:03:12.001Z"
},
"verify": "https://api.verifai.example/api/public/v1/pubkey"
}Verdict values
The score field is the calibrated probability_synthetic from Bayes-style fusion of the ensemble. confidence is the calibration confidence, driven by how many independent signals fired. Full per-signal log-odds contributions are available on the calibrated_fusion signal in the evidence blob.
| Verdict | Trigger | Meaning | |
|---|---|---|---|
authentic | score < 0.15 | - | Cleared. Safe to proceed. |
suspicious | 0.15 - 0.55 | - | Review recommended before high-value action. |
likely_synthetic | score ≥ 0.55 | - | Mandatory analyst review. Reports flag ≥ 0.85 as 'high confidence synthetic'. |
inconclusive | confidence < 0.30 | - | Not enough independent signals fired. Resubmit with more frames and audio. |
Signal glossary
| Signal | What it measures |
|---|---|
| frame_artifacts | Per-frame GAN/diffusion artifact classifier. |
| temporal_consistency | Frame-to-frame identity, lighting, and structure drift. |
| micro_expression | FACS action-unit micro-timing, blink kinematics, gaze micro-vergence, bilateral asymmetry. Cosmetic-intervention (Botox/filler) aware. |
| voice_linguistic | Prosody naturalness, breath/plosive presence, accent stability, TTS fingerprints, idiolect coherence, phoneme-level lip alignment. Requires audio_base64. |
| lip_sync | Audio-visual phoneme alignment. Merged into voice_linguistic when audio is supplied. |
| audio_synthesis | Prosody and spectral fingerprints of synthetic voice. Merged into voice_linguistic when audio is supplied. |
| provenance | C2PA manifest presence, integrity, and issuer trust. |
| compression_forensics | Double-compression and re-encode markers. |
| cardiac_liveness | rPPG - skin-chroma oscillations in the 0.7-3 Hz cardiac band. |
| physics_consistency | Catchlight geometry, shadow direction agreement, saccade micro-timing, breathing lift. |
| calibrated_fusion | Informational row (weight 0). Carries the fused probability_synthetic, calibration_confidence, and per-signal log-odds contributions. |
| avatar_synthesis_policy | Override row (weight 0). Fires when rPPG shows no cardiac pulse AND any other synthesis marker is present. |
| known_synthetic | Perceptual-hash match against the cross-org threat-intel registry. |
Errors
All errors are JSON with an error string and, where useful, a details field.
| Status | When |
|---|---|
| 400 | Body failed schema validation. details contains the field path. |
| 401 | Missing, malformed, or revoked API key. |
| 404 | Scan id not found or not owned by your org. |
| 409 | Scan has no verdict yet (receipt endpoint only). |
| 429 | Per-minute or per-month limit exceeded. See Rate limits. |
| 500 | Detection backend failure. scan_id is returned when available so you can retry the same case. |
HTTP/1.1 401 Unauthorized
{ "error": "Invalid or revoked API key" }HTTP/1.1 400 Bad Request
{ "error": "Invalid body", "details": "frames: Array must contain at most 12 element(s)" }Rate limits
Each API key is capped at 30 requests per minute and 10,000 requests per month. Every response includes:
| Header | Meaning |
|---|---|
| x-ratelimit-limit-minute | Per-minute cap (30). |
| x-ratelimit-remaining-minute | Requests left in the current UTC minute. |
| x-ratelimit-limit-month | Per-month cap (10000). |
| x-ratelimit-remaining-month | Requests left in the current UTC month. |
| retry-after | Seconds to wait before retrying (429 only). |
Every 429 is written to the tamper-evident audit log. Contact us for higher limits or streaming ingestion.
HTTP/1.1 429 Too Many Requests
retry-after: 42
x-ratelimit-remaining-minute: 0
{ "error": "Rate limit exceeded (per minute)", "retry_after_seconds": 42 }Evidence report (PDF)
Every scan can be downloaded as a signed PDF from the analyst console (PDF report on the scan detail page). The PDF includes the verdict, per-signal scores and confidences, frame timestamps, analyst notes, override history, and the current head hash of your organization's audit chain - so the report itself is verifiable against the append-only log.
Signed verdict webhooks
Configure a webhook URL per API key from Settings - API Keys. VerifAI POSTs a signed callback for every verdict, including analyst overrides. Failed deliveries retry with exponential back-off up to 5 attempts.
Headers
| Header | Meaning |
|---|---|
| x-verifai-event | verdict.completed or verdict.overridden. |
| x-verifai-delivery | Unique delivery id. Retries repeat this id. |
| x-verifai-timestamp | Unix seconds. Reject deliveries older than 5 minutes. |
| x-verifai-signature | sha256=HEX. HMAC-SHA256 over timestamp + '.' + raw body using your webhook secret. |
Body
{
"event": "verdict.completed",
"delivery_id": "wd_2f5a...",
"scan_id": "8b3f...",
"verdict": "likely_synthetic",
"score": 0.94, "confidence": 0.97,
"receipt": { "payload": "...", "signature": "...", "public_key_url": "https://verifai.uk/api/public/v1/pubkey" }
}Verification (Node)
import { createHmac, timingSafeEqual } from "crypto";
const sig = req.headers["x-verifai-signature"].split("=")[1];
const ts = req.headers["x-verifai-timestamp"];
const expected = createHmac("sha256", process.env.VERIFAI_WEBHOOK_SECRET)
.update(ts + "." + rawBody).digest("hex");
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(401).end();
if (Math.abs(Date.now()/1000 - Number(ts)) > 300) return res.status(401).end();Every dispatch and its HTTP outcome is written to the append-only webhook_deliveries table and the audit chain.
