# Deepfake check
Source: https://docs.spoofsense.ai/api-reference/detection/deepfake-check
/api-reference/openapi.json post /v1/deepfake_detection
Checks whether an image is AI-generated, and whether the face in it has been manipulated (face swap, AI edit, lip-sync, reenactment). C2PA Content Credentials are checked first: a valid credential saying the image was made or edited with AI flags it without running the models. Otherwise both detectors run and are reported in `signals`; the decision is "real" only if neither flags the image. A face is not required: with no usable face (none found, or under 64 px) only the AI-image check runs. Costs 2 credits. Send the original file — resizing, re-compressing or rotating it first removes some of what the AI-image check reads.
# Face liveness check
Source: https://docs.spoofsense.ai/api-reference/detection/face-liveness-check
/api-reference/openapi.json post /v1/liveness_detection
Checks whether the face in the image is a live capture rather than a presentation attack (printed photo, screen replay, mask). Costs 1 credit.
# Liveness + deepfake in one call
Source: https://docs.spoofsense.ai/api-reference/detection/liveness-+-deepfake-in-one-call
/api-reference/openapi.json post /v1/unified_detection
Runs both checks on the same image. The overall `decision` is `real` only when every check passes its threshold; per-product outcomes are in `checks`. Costs 3 credits.
# Health check
Source: https://docs.spoofsense.ai/api-reference/service/health-check
/api-reference/openapi.json get /health
# Create a verification session
Source: https://docs.spoofsense.ai/api-reference/verification-sessions/create-a-verification-session
/api-reference/openapi.json post /v1/verification_sessions
Called from your backend with the secret key. Mints a single-use `sst_` client token — returned exactly once, only its hash is stored. Products and thresholds are fixed here, server-side; the client can never change them. Fails fast with 402 if your balance is empty, so you never hand out a doomed token.
# Peek at a session (client token)
Source: https://docs.spoofsense.ai/api-reference/verification-sessions/peek-at-a-session-client-token
/api-reference/openapi.json get /v1/verification_sessions/session_info
Called by the SDK at mount, authenticated with the `sst_` client token. Returns the session's nonce (required in the submit payload) and status, so an expired or used session fails fast before the camera opens.
# Retrieve a session's result
Source: https://docs.spoofsense.ai/api-reference/verification-sessions/retrieve-a-sessions-result
/api-reference/openapi.json get /v1/verification_sessions/{id}
Called from your backend with the secret key. The only trustworthy read of the decision — treat the user as verified only when `status` is `complete` AND `decision` is `real`. When a capture was blocked for suspected injection, `checks` is null and `risk` carries only `{injection_blocked: true, reason: "suspected_injection"}` — scores are withheld so flagged sessions can't probe the detectors.
# Retrieve the captured selfie
Source: https://docs.spoofsense.ai/api-reference/verification-sessions/retrieve-the-captured-selfie
/api-reference/openapi.json get /v1/verification_sessions/{id}/media
Called from your backend with the secret key — the sst_ client token can never reach this endpoint. Mints a signed URL to the exact bytes the models scored, valid for 5 minutes; fetch immediately rather than storing the link. Verify the download against `sha256`. Every retrieval is written to your org's media access log. Available as soon as the decision is — the upload happens during capture.
# Submit a capture (client token)
Source: https://docs.spoofsense.ai/api-reference/verification-sessions/submit-a-capture-client-token
/api-reference/openapi.json post /v1/verification_sessions/submit
Called by the SDK with the `sst_` client token. Multipart only — no URLs, no raw bodies, no threshold overrides. The response is deliberately minimal: the untrusted client never sees scores or the decision. 3 attempts per session; capture-fault errors (413, 422, NONCE_MISMATCH) consume an attempt, server-fault errors refund it.
# Authentication
Source: https://docs.spoofsense.ai/guides/authentication
Secret keys and session tokens
SpoofSense uses two kinds of credentials, for two trust levels.
## Secret keys (`sk_live_…`)
Created in the console under **Dashboard → API keys**. Sent on every server-side call, either as a bearer token or an `x-api-key` header:
```bash theme={null}
# Preferred
curl https://api.spoofsense.ai/v1/liveness_detection \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" ...
# Equivalent
curl https://api.spoofsense.ai/v1/liveness_detection \
-H "x-api-key: $SPOOFSENSE_API_KEY" ...
```
* The plaintext key is shown **once** at creation; only a SHA-256 hash and the last four characters are stored. Lost keys can't be recovered — revoke and create a new one.
* A revoked or disabled key returns `401 UNAUTHORIZED`.
* When your credit balance reaches zero, keys stop working until you top up ([Credits & billing](/guides/credits)).
Secret keys authorize spending your credits **and** retrieving stored biometric captures. Keep them in server-side environment variables only — never in client code, mobile builds, or version control.
## Session client tokens (`sst_…`)
Short-lived, single-use tokens for the [verification session](/verification-sessions/overview) capture flow. Your backend mints one by creating a session with its secret key, then hands **only the token** to the browser or app:
```text theme={null}
Authorization: Bearer sst_...
```
A session token can do exactly two things: read its own session's metadata (`GET /v1/verification_sessions/session_info`) and submit a capture (`POST /v1/verification_sessions/submit`). It can never read a decision, a score, or stored media — so a token leaked from a client is not a path to anything sensitive.
| | `sk_live_…` secret key | `sst_…` session token |
| - | - | - |
| Lives in | Your server | Browser / mobile app |
| Lifetime | Until revoked | Session TTL (default 15 min) |
| Detection endpoints | ✅ | ❌ |
| Create sessions, read results, fetch media | ✅ | ❌ |
| Submit a capture | ❌ | ✅ |
# Credits & billing
Source: https://docs.spoofsense.ai/guides/credits
What a call costs and how balances behave
SpoofSense is prepaid: you buy credits in the console, and each successful check decrements your balance.
## Costs
| Check | Credits |
| - | - |
| Face liveness (`/v1/liveness_detection`) | 1 |
| Deepfake (`/v1/deepfake_detection`) | 2 |
| Unified (`/v1/unified_detection`) | 3 |
| Verification session — liveness only | 1 |
| Verification session — deepfake only | 2 |
| Verification session — both (default) | 3 |
New organizations start with free trial credits, so you can integrate before paying.
## Rules
* **You're only charged for scored checks.** Input errors — invalid image, no face detected, image too large, bad threshold, backlit capture — return 4xx and cost nothing.
* **Every response tells you where you stand** via `credits_remaining`.
* **Zero balance disables your keys.** Calls return `402 INSUFFICIENT_CREDITS` until you top up in the console. Creating a verification session also pre-checks the balance, so you'll never hand your client a token that's doomed to fail.
* **Verification session retries are free** until a check actually runs: a `422` retake (blur, no face) doesn't charge; the charge lands when the capture is scored.
Surface `402 INSUFFICIENT_CREDITS` to your **operations team**, not your end user — it means the account needs a top-up, not that verification failed.
# Deepfake signals
Source: https://docs.spoofsense.ai/guides/deepfake-signals
Content Credentials, the two deepfake detectors, and how to read the breakdown
`/v1/deepfake_detection` first checks the file for [C2PA Content Credentials](#content-credentials-c2pa), then runs two detectors and reports everything in a `signals` object:
| Signal | Looks at | Catches | Runs when |
| - | - | - | - |
| `ai_generated` | The whole image | Images made or edited by generative AI: diffusion models, GPT-style image models, AI inpainting. Works on any content: faces, documents, products, art | Always |
| `face_manipulation` | The largest face, cropped with some margin | Face swaps, AI face edits, lip-sync and reenactment frames, talking-head avatars | A face is found and it's at least 64 px |
The image is `"real"` only if **neither** signal flags it. The top-level `genuine_score` is the lower of the two, so `decision` and `threshold` work exactly as on the other endpoints.
```json Deepfake response theme={null}
{
"product": "deepfake",
"decision": "spoof",
"genuine_score": 0.139415,
"threshold": 0.5,
"spoof_type": null,
"latency_ms": 640,
"credits_remaining": 1238,
"session_id": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"request_id": "0f1e2d3c4b5a69788766554433221100",
"signals": {
"content_credentials": { "decision": null, "genuine_score": null, "status": "not_found" },
"ai_generated": { "decision": "real", "genuine_score": 0.866742 },
"face_manipulation": { "decision": "spoof", "genuine_score": 0.139415,
"status": "checked", "face_box": [64, 82, 148, 148] }
}
}
```
That example is a face swap: the photo as a whole looks camera-made, but the face was replaced.
## Fields
| Field | Type | Meaning |
| - | - | - |
| `signals.*.decision` | `"real"` \| `"spoof"` \| `null` | This signal at your threshold. `null` when it didn't run |
| `signals.*.genuine_score` | number \| `null` | Calibrated like the top-level score: `0.5` is the boundary, higher is more genuine |
| `ai_generated.kind` | `"generated"` \| `"edited"` | Present only when `ai_generated` flags the image: whether it looks made from scratch by AI or a real photo edited with AI. A hint, not a forensic finding |
| `face_manipulation.status` | `"checked"` \| `"no_face"` \| `"face_too_small"` \| `"skipped"` | Whether the face check ran. `"skipped"` (also on `ai_generated`) means Content Credentials already decided |
| `face_manipulation.face_box` | `[x, y, w, h]` | The face that was judged, in pixels of the image as sent (no EXIF rotation). Absent when no face was found |
## Content Credentials (C2PA)
ChatGPT, Gemini (Nano Banana), Adobe Firefly and a growing list of other generators sign the images they make with [C2PA Content Credentials](https://c2pa.org): a cryptographically signed record of how the file was made. We check for one **before** running the models. If a valid credential says the image was generated or edited with AI, that settles it: the image is flagged with `genuine_score: 0` and the models don't run.
```json Decided by Content Credentials theme={null}
"genuine_score": 0.0,
"signals": {
"content_credentials": { "decision": "spoof", "genuine_score": 0.0, "status": "ai_generated",
"generator": "Gemini", "signed_by": "Google LLC" },
"ai_generated": { "decision": null, "genuine_score": null, "status": "skipped" },
"face_manipulation": { "decision": null, "genuine_score": null, "status": "skipped" }
}
```
| `content_credentials.status` | Meaning | Decides? |
| - | - | - |
| `ai_generated` | Signed credential says the image was generated by AI | Yes, flagged |
| `ai_edited` | Signed credential says AI was used to edit or composite it | Yes, flagged |
| `no_ai_claim` | Credential present (e.g. from a camera or editor), no AI involved | No, the models decide |
| `invalid` | Credential present but the image was altered after signing, or it can't be verified | No, the models decide |
| `not_found` | No credential in the file | No, the models decide |
* `generator` and `signed_by` appear only when the signer chains to the official C2PA trust list (or the earlier Content Authenticity Initiative list), so they can't be spoofed with a self-made certificate.
* An AI image that was later cropped or adjusted in a non-AI tool still counts: the AI step stays in its signed history.
* A credential can prove an image is AI-made, but never that it's real, so `content_credentials.decision` is only ever `"spoof"` or `null`.
* Credentials are fragile. Screenshots, re-saves, messaging apps and most social platforms strip them, so a missing credential tells you nothing. That's why the models still run on everything else.
## Images without a face
A face isn't required. If there's none, or the largest one is under 64 px, the face check is skipped and `ai_generated` decides alone:
```json theme={null}
"signals": {
"ai_generated": { "decision": "spoof", "genuine_score": 0.0252, "kind": "generated" },
"face_manipulation": { "decision": null, "genuine_score": null, "status": "face_too_small", "face_box": [210, 144, 62, 62] }
}
```
If your flow needs a face (selfie onboarding, for example), check `face_manipulation.status === "checked"` on your side, or use `/v1/unified_detection`, whose liveness check rejects images without a usable face.
## Thresholds
Your org's deepfake threshold (default `0.5`) applies to both signals at once. At `0.5`, each signal sits at its identity-verification operating point: about 0.5% of genuine selfies rejected per signal, roughly 0.9% combined. A higher threshold catches more and rejects more genuine images. See [Thresholds & scores](/guides/thresholds).
## Getting the best results
* **Send the original file.** Resizing, re-compressing, screenshotting or rotating removes the fine traces the AI-image check reads, and strips Content Credentials.
* **JPEG, PNG, WebP, AVIF, BMP, TIFF and GIF** work. HEIC doesn't, so convert iPhone photos first.
* **Video:** sample frames (one per second is plenty) and send each one. There's no calibrated clip-level rule yet; flagging a clip when any frame is `"spoof"` is a reasonable start.
## Known limits
* Talking-head avatars and lip-sync from tools the model hasn't seen are the hardest case.
* Heavily compressed or downscaled real photos get flagged a little more often.
* AI-edited documents, receipts and ID cards are mostly missed. Don't rely on this endpoint for document forgery.
* Faces under 64 px aren't checked by `face_manipulation`. `ai_generated` still runs.
# Errors
Source: https://docs.spoofsense.ai/guides/errors
Every error code, and what to do about it
All errors use one envelope:
```json theme={null}
{
"error": {
"code": "FACE_NOT_DETECTED",
"message": "No face found in the image"
}
}
```
Branch on `error.code`, not the message — messages may be reworded.
## Detection endpoints
| Status | Code | Meaning | What to do |
| - | - | - | - |
| 400 | `BAD_REQUEST` | Malformed body, missing image field, or unfetchable `image_url` | Fix the request |
| 400 | `INVALID_THRESHOLD` | `threshold` outside `[0, 1]` or not a number | Fix the value |
| 401 | `UNAUTHORIZED` | Missing, invalid, or revoked API key | Check the key; create a new one if revoked |
| 402 | `INSUFFICIENT_CREDITS` | Balance is empty | Top up in the console — operator issue, not a user issue |
| 413 | `IMAGE_TOO_LARGE` | Payload over 10 MB | Send the original capture, not a scan/export |
| 422 | `INVALID_IMAGE` | Bytes aren't a decodable image (or HEIC) | Check encoding — common with double-base64 |
| 422 | `FACE_NOT_DETECTED` | No face found (liveness and unified only) | Have the user retake |
| 422 | `FACE_TOO_SMALL` | Face bounding box under 224×224 px (liveness and unified only) | Retake closer to the camera; don't downscale before sending. Or [disable the gate](/guides/image-inputs#disabling-the-minimum-face-size-gate) with `min_face_check: false` |
| 422 | `IMAGE_BACKLIT` | Strong backlight makes liveness unreliable | Retake with light on the face, not behind it |
| 500 | `INTERNAL` | Unexpected server error | Retry; contact us if it persists |
| 502 | `UPSTREAM_ERROR` | Inference service unavailable | Retry with backoff |
No 4xx error is ever charged. `422` codes are **user-fixable** — build your retake UX around them. `/v1/deepfake_detection` never returns the face errors: an image without a usable face is still scored, and `signals.face_manipulation.status` says why the face check was skipped.
## Verification session endpoints
| Status | Code | Meaning | What to do |
| - | - | - | - |
| 400 | `NONCE_MISMATCH` | `client_payload.nonce` doesn't match the session | Fetch the nonce from `session_info` first; consumes an attempt |
| 401 | `INVALID_SESSION_TOKEN` | `sst_` token missing, wrong, or already invalid | Mint a fresh session from your backend |
| 404 | `SESSION_NOT_FOUND` | No such session id for your organization | Check the id |
| 404 | `MEDIA_NOT_AVAILABLE` | No stored capture (storage off, retention passed, or erased) | See the message; check `media.reason` on the session read |
| 409 | `SESSION_NOT_PENDING` | Session already completed or failed | Create a new session |
| 410 | `SESSION_EXPIRED` | TTL elapsed before completion | Create a new session |
| 429 | `TOO_MANY_ATTEMPTS` | All capture attempts used (3 by default) | Create a new session |
### Which submit errors consume an attempt
A session allows **3 capture attempts**. Errors caused by the capture itself — `413`, any `422`, and `NONCE_MISMATCH` — consume the attempt. Errors that aren't the capture's fault — bad form encoding, `INSUFFICIENT_CREDITS`, `UPSTREAM_ERROR` — refund it, so server-side trouble never burns the user's retry budget. When the last attempt is consumed by a failure, the session becomes `failed` with `failure_code: "ATTEMPTS_EXHAUSTED"`.
# Image inputs
Source: https://docs.spoofsense.ai/guides/image-inputs
Every way to send an image to the detection endpoints
The three detection endpoints (`/v1/liveness_detection`, `/v1/deepfake_detection`, `/v1/unified_detection`) accept the same image in four interchangeable forms. Pick whichever fits your stack.
## Formats
```bash theme={null}
curl -X POST https://api.spoofsense.ai/v1/liveness_detection \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" \
-F "file=@selfie.jpg" \
-F "threshold=0.6" # optional
```
The field name `file` is canonical; `image` is accepted as an alias.
```bash theme={null}
curl -X POST https://api.spoofsense.ai/v1/liveness_detection \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"data": "'"$(base64 -i selfie.jpg)"'"}'
```
`data` accepts plain base64 **or** a full data URL (`data:image/jpeg;base64,…`). `image` is accepted as an alias for `data`.
```bash theme={null}
curl -X POST https://api.spoofsense.ai/v1/liveness_detection \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"image_url": "https://example.com/selfie.jpg"}'
```
We fetch the URL server-side (http/https only, 10-second timeout). `url` is accepted as an alias.
```bash theme={null}
curl -X POST https://api.spoofsense.ai/v1/liveness_detection \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary @selfie.jpg
```
Any request body that isn't JSON or multipart is treated as raw image bytes.
## Requirements and behavior
* **Formats:** JPEG, PNG, WebP, GIF, BMP — anything a standard image decoder reads. HEIC isn't supported, so convert iPhone photos to JPEG or PNG. Undecodable input returns `422 INVALID_IMAGE`.
* **Size limit:** 10 MB, enforced on the decoded payload and on URL downloads alike (`413 IMAGE_TOO_LARGE`).
* **Orientation:** liveness applies EXIF rotation automatically. Deepfake detection scores the file exactly as sent, so `face_box` coordinates refer to the unrotated pixels.
* **One clear face (liveness and unified):** the image must contain a detectable face, and its bounding box must be at least **224×224 pixels** (`422 FACE_NOT_DETECTED` / `FACE_TOO_SMALL`). The error message reports the detected size, e.g. `Detected face 180x176 smaller than minimum 224x224`. In practice, any uncropped selfie from a modern phone camera clears this comfortably — it matters when faces are small in the frame or images were downscaled. If your capture flow can't guarantee close-up faces, you can [turn this gate off per request](#disabling-the-minimum-face-size-gate).
* **Lighting:** heavily backlit captures are rejected with `422 IMAGE_BACKLIT` rather than scored unreliably — prompt the user to retake with light on the face.
* **Deepfake detection takes any image.** It has no face requirement: a photo without a usable face (none found, or under 64 px) is still checked for AI generation, and the response says the face check was skipped. See [Deepfake signals](/guides/deepfake-signals).
**Don't downscale, re-encode or rotate before sending.** Deepfake detection reads fine pixel-level traces that resizing and re-compression destroy — send the original file. Failed input validation (any 4xx) is never charged.
## Threshold overrides
All input forms accept an optional per-request threshold (JSON body or multipart field):
* Single endpoints: `threshold` — a number in `[0, 1]`.
* Unified: `thresholds` — an object like `{"face_liveness": 0.6, "deepfake": 0.4}` (JSON body only). A plain `threshold` applies to both.
Invalid values return `400 INVALID_THRESHOLD`. See [Thresholds & scores](/guides/thresholds) for when to override.
## Disabling the minimum face size gate
If your images legitimately contain smaller faces — CCTV frames, wide-angle kiosks, archived photos — pass `min_face_check: false` (JSON body or multipart field) to skip liveness's 224×224 gate and score the image anyway (deepfake detection has no such gate to skip):
```json theme={null}
{ "data": "", "min_face_check": false }
```
Two things to know before you turn it off:
* **Accuracy degrades with face size.** The models were trained on faces of at least 224 px; smaller faces are upscaled before scoring, and the further below 224 px they are, the less reliable `genuine_score` becomes. Prefer fixing the capture when you can.
* **A face must still be detectable** — `FACE_NOT_DETECTED` is unaffected by this flag.
The flag applies to the single-product and unified endpoints. Verification sessions (SDK flow) always keep the gate on: the capture UI controls framing, so small faces there indicate a capture problem, not a use case.
# Thresholds & scores
Source: https://docs.spoofsense.ai/guides/thresholds
How genuine_score and decision work, and when to tune the threshold
Every check produces a `genuine_score` in `[0, 1]` — higher means more likely a genuine, live capture — and applies one rule:
```text theme={null}
decision = genuine_score > threshold
```
## The score is calibrated
Raw model probabilities shift when models are retrained. SpoofSense remaps every model's output so that **the decision boundary always sits at 0.5**: a threshold you tune today keeps meaning the same thing after a model update, and thresholds are comparable across products.
Model internals (architecture, raw per-class probabilities) are deliberately not exposed.
## Where the threshold comes from
Highest priority wins:
1. **Per-request override** — `threshold` (or `thresholds` on unified) in the request body.
2. **Organization default** — set per product in the console (**Dashboard → Settings**).
3. **Platform default** — `0.5`.
The threshold actually applied is echoed back in every response, so your logs always show which rule made the call.
## Tuning guidance
| You want | Move the threshold | Trade-off |
| - | - | - |
| Catch more spoofs (stricter) | Up, e.g. `0.6` | More genuine users rejected (higher false-reject rate) |
| Fewer false rejections (looser) | Down, e.g. `0.4` | More spoofs slip through |
* Start at the default `0.5` and only move after measuring on your own traffic.
* For [verification sessions](/verification-sessions/overview), thresholds are fixed **server-side at session creation** — the client can never loosen them.
* On `/v1/unified_detection`, the overall `decision` is `"real"` only if **every** product's check passes its own threshold; the per-product outcomes are in `checks`.
* Deepfake detection runs two detectors. Your deepfake threshold applies to both [signals](/guides/deepfake-signals), each calibrated so `0.5` is its boundary, so moving it tightens or loosens both together.
```json Unified response theme={null}
{
"product": "unified",
"decision": "spoof",
"checks": {
"face_liveness": { "decision": "real", "genuine_score": 0.91, "threshold": 0.5 },
"deepfake": { "decision": "spoof", "genuine_score": 0.31, "threshold": 0.5 }
},
"latency_ms": 420,
"credits_remaining": 997,
"session_id": { "face_liveness": "…", "deepfake": "…" },
"request_id": "…"
}
```
# Introduction
Source: https://docs.spoofsense.ai/index
Face liveness and deepfake detection, behind one API
SpoofSense tells you whether a face in an image is a **real, live person** — not a printed photo, a screen replay, a mask, or an AI-generated deepfake. You send an image (or let our SDKs capture one), we return a decision.
## Two products, three ways to call them
Passive liveness from a single frame — catches presentation attacks such as printed photos, screen replays, and masks. No blink/turn challenges.
Two detectors in one call: one checks whether the whole image is AI-generated, the other whether the face in it was swapped, edited, or re-animated. Works on any image, face or not. [How the signals work](/guides/deepfake-signals).
You already have the image? `POST` it to `/v1/liveness_detection`, `/v1/deepfake_detection`, or `/v1/unified_detection` and get a decision back in one round trip.
Verifying a live user? Our Web and Android SDKs capture the selfie from the camera, protect against injection attacks, and keep scores away from the untrusted client.
Zero frontend code: redirect the user to our hosted capture page, or embed it in an iframe.
## How a decision works
Every check returns a calibrated `genuine_score` between 0 and 1 and applies:
```text theme={null}
decision = genuine_score > threshold (default threshold: 0.5)
```
The score is calibrated so the decision boundary sits at 0.5 across model updates — a threshold you tune today keeps meaning the same thing after we retrain. See [Thresholds & scores](/guides/thresholds).
## Base URL
```text theme={null}
https://api.spoofsense.ai
```
All endpoints are served over HTTPS. Errors always use one envelope: `{"error": {"code", "message"}}` — see [Errors](/guides/errors).
## Get started
1. [Create an account](https://app.spoofsense.ai) — new organizations start with free trial credits.
2. Create an API key in the console (**Dashboard → API keys**).
3. Make your [first call in five minutes](/quickstart).
# Quickstart
Source: https://docs.spoofsense.ai/quickstart
Your first liveness check in five minutes
Sign in at [app.spoofsense.ai](https://app.spoofsense.ai) and create a key under **Dashboard → API keys**. Keys look like `sk_live_…` and are shown **once** — we store only a hash.
```bash theme={null}
export SPOOFSENSE_API_KEY="sk_live_..."
```
The secret key must only ever live on your server. Never ship it in a browser, mobile app, or public repo.
Send any face image — as a file, base64 JSON, or a URL. See [Image inputs](/guides/image-inputs) for all formats.
```bash cURL theme={null}
curl -X POST https://api.spoofsense.ai/v1/liveness_detection \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" \
-F "file=@selfie.jpg"
```
```javascript Node.js theme={null}
import fs from "node:fs";
const form = new FormData();
form.append("file", new Blob([fs.readFileSync("selfie.jpg")]), "selfie.jpg");
const res = await fetch("https://api.spoofsense.ai/v1/liveness_detection", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SPOOFSENSE_API_KEY}` },
body: form,
});
console.log(await res.json());
```
```python Python theme={null}
import os, requests
res = requests.post(
"https://api.spoofsense.ai/v1/liveness_detection",
headers={"Authorization": f"Bearer {os.environ['SPOOFSENSE_API_KEY']}"},
files={"file": open("selfie.jpg", "rb")},
)
print(res.json())
```
```json Response theme={null}
{
"product": "face_liveness",
"decision": "real",
"genuine_score": 0.9333,
"threshold": 0.5,
"spoof_type": null,
"latency_ms": 190,
"credits_remaining": 1240,
"session_id": "d2f8…",
"request_id": "9b1c…"
}
```
Act on `decision` (`"real"` or `"spoof"`). `genuine_score` and `threshold` are there for tuning and audit — see [Thresholds & scores](/guides/thresholds).
Same request shape, different endpoint:
```bash theme={null}
curl -X POST https://api.spoofsense.ai/v1/unified_detection \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" \
-F "file=@selfie.jpg"
```
`/v1/unified_detection` runs both checks and returns `decision: "real"` only when **every** check passes, plus a per-product `checks` object. Deepfake results also carry a `signals` breakdown (AI-generated image, face manipulation). See [Deepfake signals](/guides/deepfake-signals).
## Next steps
For onboarding/KYC, don't upload user images yourself — let the SDK capture from the camera with injection protection.
Every endpoint, parameter, and response field.
# Android SDK
Source: https://docs.spoofsense.ai/verification-sessions/android
Native capture with CameraX and Play Integrity signals
The Android SDK opens a full-screen capture activity, captures from the front camera, and submits with device-integrity signals. Distributed via JitPack.
You need a `clientToken` (`sst_…`) minted by [your backend](/verification-sessions/overview) — never embed your secret key in the app.
## Install
```kotlin theme={null}
// settings.gradle.kts
dependencyResolutionManagement {
repositories { maven("https://jitpack.io") }
}
// build.gradle.kts
implementation("com.github.kartik3ya:spoofsense-android-sdk:v0.1.0")
```
## Launch and handle the result
```kotlin theme={null}
// In your Activity/Fragment
val verify = registerForActivityResult(SpoofSenseVerification()) { result ->
when (result) {
is VerificationResult.Complete -> notifyBackend(result.verificationSessionId)
is VerificationResult.Failed -> Log.w("spoofsense", result.code)
VerificationResult.Canceled -> {}
}
}
verify.launch(SpoofSenseConfig(sessionToken = clientToken))
```
| Result | Meaning |
| - | - |
| `Complete` | Capture submitted. Send `verificationSessionId` to your backend, which [reads the decision](/verification-sessions/results) with the secret key |
| `Failed` | Terminal failure — `code` is a [standard error code](/guides/errors) such as `SESSION_EXPIRED` or `TOO_MANY_ATTEMPTS`. Mint a new session to retry |
| `Canceled` | The user backed out |
## Behavior notes
* Capture comes from the live camera (CameraX); there is no gallery or file input, by design.
* The SDK validates the session and fetches its nonce before opening the camera, and handles retryable `422` retakes internally up to the 3-attempt limit.
* Device integrity signals ride along with the submission and feed the [injection-protection policy](/verification-sessions/overview#injection-protection).
`Complete` means "a capture was submitted", not "the user is verified". Only your backend's session read with `status: "complete"` and `decision: "real"` means verified.
# Hosted verify page
Source: https://docs.spoofsense.ai/verification-sessions/hosted
Zero frontend code — redirect or embed our capture page
The hosted page at `https://app.spoofsense.ai/verify` runs the same capture flow as the Web SDK, served from our origin. You create the session on your backend, then either redirect the user to the page or embed it in an iframe.
The client token always travels in the **URL fragment** (`#token=…`), never a query parameter — fragments don't reach servers, so the token never lands in access logs.
## Redirect mode
```javascript theme={null}
// After creating the session on your backend, send the user to the hosted page.
const verifyUrl =
"https://app.spoofsense.ai/verify" +
`#token=${session.client_token}` +
`&return_url=${encodeURIComponent("https://yourapp.com/kyc/continue")}`;
res.redirect(verifyUrl);
```
When the user finishes, they return to your `return_url` with:
```text theme={null}
?spoofsense_status=complete&spoofsense_session_id=vs_…
```
(`spoofsense_status` can also be `cancelled` or `error`.) The query string is a **hint, not proof** — always read the decision server-side with your secret key.
## Embed (iframe) mode
```html theme={null}
```
### Messages posted to your page
| Message | Payload |
| - | - |
| `spoofsense:complete` | `verificationSessionId`, `capturedFrame` (Blob), `capturedFrameSha256`, `width`, `height` |
| `spoofsense:cancel` | — |
| `spoofsense:error` | `code`, `message` ([standard error codes](/guides/errors)) |
The captured frame rides the message only because `&origin=` named your page as the target. Redirect mode can't carry the image — your backend retrieves it from the [media endpoint](/verification-sessions/results#retrieving-the-captured-selfie) instead.
Always verify `e.origin` before trusting a message, and treat completion as a hint: the decision only counts when your backend reads `status: "complete"` and `decision: "real"` with the secret key.
# How sessions work
Source: https://docs.spoofsense.ai/verification-sessions/overview
The secure capture flow behind the SDKs and hosted page
When you're verifying a **live user** (onboarding, KYC, step-up auth), don't accept an image from the client and forward it yourself — a fraudster's client can send anything. Verification sessions move capture into a trusted SDK and keep every decision server-side.
## The flow
`POST /v1/verification_sessions` with your secret key. You choose the products and thresholds **here, server-side** — the client can never downgrade them. The response contains a single-use client token (`sst_…`), returned exactly once.
```javascript Node.js theme={null}
const res = await fetch("https://api.spoofsense.ai/v1/verification_sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SPOOFSENSE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
products: ["face_liveness", "deepfake"], // both = 3 credits
reference_id: "user_123", // your own user/txn id (optional)
}),
});
const session = await res.json();
// Hand ONLY session.client_token to your frontend.
// Keep session.id server-side to fetch the result later.
```
```python Python theme={null}
import os, requests
res = requests.post(
"https://api.spoofsense.ai/v1/verification_sessions",
headers={"Authorization": f"Bearer {os.environ['SPOOFSENSE_API_KEY']}"},
json={"products": ["face_liveness", "deepfake"], "reference_id": "user_123"},
)
session = res.json()
# Hand ONLY session["client_token"] to your frontend.
```
```bash cURL theme={null}
curl -X POST https://api.spoofsense.ai/v1/verification_sessions \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"products": ["face_liveness", "deepfake"], "reference_id": "user_123"}'
# → { "id": "vs_…", "client_token": "sst_…", "status": "created", … }
```
Mount the [Web SDK](/verification-sessions/web), launch the [Android SDK](/verification-sessions/android), or use the [hosted page](/verification-sessions/hosted) with the client token. The SDK captures a frame from the **live camera** (no file uploads), attaches anti-injection signals, and submits it. The submit response is deliberately minimal — the client never sees scores or the decision, so it can't be used as an oracle.
When the client reports completion, fetch the session by id with your **secret key** and act on it. Treat anything other than `status: "complete"` **and** `decision: "real"` as not verified. See [Results & media](/verification-sessions/results).
The client's "I'm done" callback is a **hint, not proof** — a tampered client can claim anything. The only trustworthy decision is the one your backend reads with the secret key.
## Session options
All fields on create are optional:
| Field | Default | Notes |
| - | - | - |
| `products` | `["face_liveness", "deepfake"]` | Subset of the two product ids. Liveness only = 1 credit, deepfake only = 2, both = 3 |
| `thresholds` | Org settings | Per-product override, fixed for the whole session |
| `ttl_seconds` | `900` | Clamped to 60–3600. Session expires if not completed in time |
| `reference_id` | — | Your own user/transaction id, ≤ 256 chars, echoed on reads |
| `metadata` | — | Arbitrary JSON object, ≤ 4 KB, echoed on reads |
## Lifecycle
```text theme={null}
created ──submit ok──▶ complete
│ └──3 failed attempts──▶ failed (failure_code: ATTEMPTS_EXHAUSTED)
└──TTL elapses──▶ expired
```
* **3 capture attempts** per session — a blurry or faceless capture (`422`) lets the user retry; see [which errors consume an attempt](/guides/errors#which-submit-errors-consume-an-attempt).
* Creating a session **pre-checks your credit balance**, so you never hand out a token that can't succeed. The charge itself happens when a capture is scored.
* The `sst_` token is stored hashed, expires with the session, and can never read decisions or media.
## Injection protection
Presentation attacks (photos, screens) are caught by the models. **Injection attacks** — virtual cameras feeding a synthetic stream — can look perfectly genuine, so the SDKs collect integrity signals at capture time. Under the `enforce` policy (console setting), a high-confidence signal such as a known virtual-camera device forces `decision: "spoof"` even when the image itself scores as real. The client is never told this happened; the reason appears only on your server-side result read.
# Results & media
Source: https://docs.spoofsense.ai/verification-sessions/results
Reading the decision and retrieving the captured selfie — server-side
Both calls on this page require your **secret key**. The `sst_` client token can never reach them.
## Reading the result
When your client reports completion, fetch the session by id:
```javascript Node.js theme={null}
const res = await fetch(`https://api.spoofsense.ai/v1/verification_sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${process.env.SPOOFSENSE_API_KEY}` },
});
const result = await res.json();
if (result.status === "complete" && result.decision === "real") {
// Genuine live capture: proceed.
} else {
// "spoof", "failed", or "expired": reject or re-verify.
}
```
```python Python theme={null}
res = requests.get(
f"https://api.spoofsense.ai/v1/verification_sessions/{session_id}",
headers={"Authorization": f"Bearer {os.environ['SPOOFSENSE_API_KEY']}"},
)
result = res.json()
ok = result["status"] == "complete" and result["decision"] == "real"
```
```bash cURL theme={null}
curl https://api.spoofsense.ai/v1/verification_sessions/vs_… \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY"
```
```json Response theme={null}
{
"object": "verification_session",
"id": "vs_…",
"status": "complete",
"decision": "real",
"checks": {
"face_liveness": { "decision": "real", "genuine_score": 0.94, "threshold": 0.5 },
"deepfake": { "decision": "real", "genuine_score": 0.88, "threshold": 0.5,
"signals": {
"ai_generated": { "decision": "real", "genuine_score": 0.97 },
"face_manipulation": { "decision": "real", "genuine_score": 0.88,
"status": "checked", "face_box": [212, 160, 290, 290] }
} }
},
"risk": {
"attestation": "not_evaluated",
"injection_blocked": false,
"signals": { "duplicate_image": false }
},
"media": { "available": true, "sha256": "…", "content_type": "image/jpeg", "expires_at": "…" },
"products": ["face_liveness", "deepfake"],
"reference_id": "user_123",
"metadata": null,
"attempts": 1,
"failure_code": null,
"sdk": { "platform": "web", "version": "0.2.0" },
"created_at": "…", "expires_at": "…", "captured_at": "…", "completed_at": "…"
}
```
**The verification rule:** the user is verified only when `status` is `"complete"` **and** `decision` is `"real"`. Everything else — `"spoof"`, `failed`, `expired`, or a value relayed by the client — is not verified.
### The `risk` object
* `signals.duplicate_image` — this exact capture was already submitted in another of your sessions; a soft signal worth reviewing.
* `attestation` — status of device attestation (e.g. Play Integrity) when provided by the SDK.
* **Injection-blocked sessions**: when a capture was rejected for suspected injection (e.g. a known virtual camera under the `enforce` policy), `decision` is `"spoof"`, `checks` is `null`, and `risk` is exactly `{ "injection_blocked": true, "reason": "suspected_injection" }`. Scores and signal details are deliberately withheld so a flagged session can't be used to probe the detectors.
## Retrieving the captured selfie
Liveness and deepfake checks say the face is real — not *whose* it is. If you match the capture against an ID document, keep KYC records, or run manual review, retrieve the exact image the models scored:
```javascript Node.js theme={null}
const media = await fetch(
`https://api.spoofsense.ai/v1/verification_sessions/${sessionId}/media`,
{ headers: { Authorization: `Bearer ${process.env.SPOOFSENSE_API_KEY}` } },
).then((r) => r.json());
// media.url is a signed link valid for 5 minutes — fetch the bytes now.
```
```python Python theme={null}
media = requests.get(
f"https://api.spoofsense.ai/v1/verification_sessions/{session_id}/media",
headers={"Authorization": f"Bearer {os.environ['SPOOFSENSE_API_KEY']}"},
).json()
selfie = requests.get(media["url"]).content # signed link, valid 5 minutes
```
```bash cURL theme={null}
# ?redirect=true 302s straight to the image bytes
curl -L -o selfie.jpg \
"https://api.spoofsense.ai/v1/verification_sessions/vs_…/media?redirect=true" \
-H "Authorization: Bearer $SPOOFSENSE_API_KEY"
```
* The image is available **as soon as the decision is** — it uploads during capture, so there's nothing to poll.
* `url` is a signed link valid for **5 minutes**. Fetch the bytes immediately; don't store the link.
* Verify your download against `sha256` — it hashes the exact bytes the models scored, and matches the SDK's `capturedFrameSha256`.
* Every retrieval is written to your organization's media access log, keeping biometric access auditable.
### When media isn't available
`GET …/media` returns `404 MEDIA_NOT_AVAILABLE`, and the session read's `media` block explains why:
| `media.reason` | Meaning |
| - | - |
| `not_captured` | The session never completed a capture |
| `storage_disabled` | Your org has media storage off (**Settings → Compliance**) |
| `expired` | The capture passed its retention window |
| `unavailable` | The image was erased or the upload failed |
Media storage, retention windows, and erasure are controlled per organization in the console's compliance settings.
# Web SDK
Source: https://docs.spoofsense.ai/verification-sessions/web
Camera capture for browsers — React, vanilla JS, or a script tag
The Web SDK renders the camera view, guides the user, captures a frame from the **live stream** (never a file upload), and submits it with anti-injection signals. Your page gets a callback with the session id — the decision stays server-side.
Requires a browser with camera access over HTTPS. You need a `clientToken` (`sst_…`) minted by [your backend](/verification-sessions/overview) first.
```bash theme={null}
npm install @spoofsense/react
```
```tsx theme={null}
import { SpoofSenseCapture } from "@spoofsense/react";
function VerifyStep({ clientToken }: { clientToken: string }) {
return (
{
// capturedFrame is the submitted JPEG (Blob) — fine for a local
// preview or your own upload. For the authoritative copy, your
// backend fetches the session media.
setPreview(URL.createObjectURL(capturedFrame));
// Tell your backend to fetch the result for this session.
notifyBackend(verificationSessionId);
}}
onError={(e) => console.warn(e.code, e.message)}
/>
);
}
```
```bash theme={null}
npm install @spoofsense/web-sdk
```
```javascript theme={null}
import * as SpoofSense from "@spoofsense/web-sdk";
const handle = SpoofSense.mount({
sessionToken: clientToken, // the sst_… token from your backend
container: "#verify", // element or selector
onComplete: ({ verificationSessionId, capturedFrame, capturedFrameSha256 }) => {
showPreview(URL.createObjectURL(capturedFrame)); // the submitted JPEG
notifyBackend(verificationSessionId);
},
onError: (e) => console.warn(e.code, e.message),
});
// handle.unmount() when done
```
```html theme={null}
```
## The completion callback
| Field | Description |
| - | - |
| `verificationSessionId` | The `vs_…` id — send this to your backend so it can [read the result](/verification-sessions/results) |
| `capturedFrame` | The exact JPEG that was submitted, as a `Blob` — for a local preview or your own storage |
| `capturedFrameSha256` | Hash of those bytes; matches `media.sha256` on the server-side read |
`onError` receives `{ code, message }` using the [standard error codes](/guides/errors) — e.g. `SESSION_EXPIRED` means mint a fresh session, retryable `422`s are handled inside the SDK's retake flow.
## Behavior notes
* The SDK calls `GET /v1/verification_sessions/session_info` at mount to validate the token and fetch the session nonce **before** opening the camera — an expired or used session fails fast with no camera prompt.
* Retakes for user-fixable problems (no face, blur, backlight) are built in, up to the session's 3-attempt limit.
* The SDK never exposes scores or decisions to the page. Always verify server-side.
Prefer zero frontend code? Use the [hosted page](/verification-sessions/hosted) — same capture flow, served from our origin.