Redaction, wired into your product
The same face, license plate, and screen redaction that powers the Scanon.ai web app, available as a REST API - for individual developers or full business accounts.
What the API does
The Scanon API detects and redacts personally identifiable information - faces, license plates, and screens - in images and video. Send it a file, get back a redacted one; the original is never retained. It's the same detection engine that powers the Scanon.ai web app, available programmatically.
The API is identical for individual and business accounts - the only differences are your rate-limit tier and credit balance, both tied to your account's plan.
Authentication
Every request needs an API key
Create a key from your Business Dashboard or Account page, then send it as a bearer token on every request:
Authorization: Bearer scanon_your_api_keyKeys that are missing, malformed, or revoked get a 401.
Code examples
Pick a language once - every sample on this page switches with it.
Images - synchronous
POST /v1/images returns the redacted image directly
| Param | Type | Default | Notes |
|---|---|---|---|
| file | multipart file | required | Max 10MB. Must be an image content type. |
| detect_faces | form boolean | true | - |
| detect_plates | form boolean | false | - |
| detect_tattoos | form boolean | false | Blurs screens/tattoo-class regions. |
| encryption_mode | form string | "normal" | "normal" or "cryptographic". Anything else returns 400. |
- Param
- file
- Type
- multipart file
- Default
- required
- Notes
- Max 10MB. Must be an image content type.
- Param
- detect_faces
- Type
- form boolean
- Default
- true
- Notes
- -
- Param
- detect_plates
- Type
- form boolean
- Default
- false
- Notes
- -
- Param
- detect_tattoos
- Type
- form boolean
- Default
- false
- Notes
- Blurs screens/tattoo-class regions.
- Param
- encryption_mode
- Type
- form string
- Default
- "normal"
- Notes
"normal"or"cryptographic". Anything else returns 400.
On 200, the response body is the redacted image as raw image/png bytes - not JSON. The X-Image-Credits-Remaining header reports your balance after the call. On any other status, the body is JSON - see Errors below.
encryption_mode="cryptographic" AES-scrambles each detected region with a fresh, never-stored key before blurring on top - the original pixels are unrecoverable, not just visually hidden. It costs 2 credits instead of 1; an unrecognized value is rejected with 400 rather than silently falling back to normal blur.
# curl's -o writes the response body to disk no matter the status code.
# Write to a temp file first and only keep it if the status was 200 - on
# error the body is JSON (e.g. {"detail": "Insufficient credits"}), not a PNG.
STATUS=$(curl -s -o /tmp/scanon_response -w "%{http_code}" \
-X POST https://api.scanon.ai/v1/images \
-H "Authorization: Bearer scanon_your_api_key" \
-F "file=@photo.jpg" \
-F "detect_faces=true" \
-F "detect_plates=false" \
-F "detect_tattoos=false" \
-F "encryption_mode=normal")
# Change encryption_mode to "cryptographic" for AES-scrambled (irreversible)
# redaction - costs 2 image credits instead of 1.
if [ "$STATUS" -eq 200 ]; then
mv /tmp/scanon_response redacted.png
echo "Saved redacted.png"
else
echo "Request failed with status $STATUS:"
cat /tmp/scanon_response
exit 1
fiVideos - asynchronous
Submit, poll, then fetch the result
POST /v1/videos
Accepts file (multipart, required) and encryption_mode (form string, "normal" or "cryptographic", default "normal"). Returns {"job_id": "...", "status": "queued"} immediately.
# Requires jq (https://jqlang.org) for JSON parsing.
API_KEY="scanon_your_api_key"
JOB=$(curl -s -X POST https://api.scanon.ai/v1/videos \
-H "Authorization: Bearer $API_KEY" \
-F "file=@video.mp4" \
-F "encryption_mode=normal")
JOB_ID=$(echo "$JOB" | jq -r '.job_id')
echo "Submitted job $JOB_ID"GET /v1/videos/{job_id}
Returns status (queued → processing → done/failed/cancelled), progress (0–100 or null), and error (set when status is failed). Poll this until you reach a terminal status.
# Poll every 4s with a sensible max wait - never a tight loop
POLL_INTERVAL=4
MAX_WAIT=300
WAITED=0
STATUS="queued"
while [ "$STATUS" != "done" ] && [ "$STATUS" != "failed" ] && [ "$STATUS" != "cancelled" ]; do
sleep "$POLL_INTERVAL"
WAITED=$((WAITED + POLL_INTERVAL))
if [ "$WAITED" -ge "$MAX_WAIT" ]; then
echo "Timed out waiting for job $JOB_ID"
exit 1
fi
POLL=$(curl -s "https://api.scanon.ai/v1/videos/$JOB_ID" -H "Authorization: Bearer $API_KEY")
STATUS=$(echo "$POLL" | jq -r '.status')
echo "status=$STATUS progress=$(echo "$POLL" | jq -r '.progress')"
done
if [ "$STATUS" != "done" ]; then
echo "Job did not complete: $(echo "$POLL" | jq -r '.error')"
exit 1
fiGET /v1/videos/{job_id}/result
Returns the redacted video as raw video/mp4 bytes - see Result lifecycle below before you call it.
# Single fetch only - a second call to /result returns 410
HTTP_STATUS=$(curl -s -o redacted.mp4 -w "%{http_code}" \
"https://api.scanon.ai/v1/videos/$JOB_ID/result" \
-H "Authorization: Bearer $API_KEY")
if [ "$HTTP_STATUS" -eq 200 ]; then
echo "Saved redacted.mp4"
else
echo "Fetch failed ($HTTP_STATUS) - result may have expired or already been retrieved"
exit 1
fiNote: detect_faces/detect_plates/detect_tattoos apply to images only. Video submissions currently redact every class your pipeline supports (faces, plates, screens) regardless of these flags - per-request filtering for video is planned but not yet wired up.
Rate limits
Per-API-key token buckets, tier-aware
Each key gets a token bucket per endpoint. Burst is how many calls you can fire back-to-back before you're throttled; the bucket then refills at a fixed rate up to that same burst ceiling. Your tier is your account's subscription plan (free / pro / team) - resolved automatically, no configuration needed.
| Tier | Images - burst | Images - refill | Videos - burst | Videos - refill |
|---|---|---|---|---|
| free | 20 | +1 every 30s (~120/hr) | 2 | +1 every 15 min (~4/hr) |
| pro | 100 | +1 every 5s (~720/hr) | 10 | +1 every 3 min (~20/hr) |
| team | 300 | +1 every 2s (~1,800/hr) | 20 | +1 every 90s (~40/hr) |
- Tier
- free
- Images - burst
- 20
- Images - refill
- +1 every 30s (~120/hr)
- Videos - burst
- 2
- Videos - refill
- +1 every 15 min (~4/hr)
- Tier
- pro
- Images - burst
- 100
- Images - refill
- +1 every 5s (~720/hr)
- Videos - burst
- 10
- Videos - refill
- +1 every 3 min (~20/hr)
- Tier
- team
- Images - burst
- 300
- Images - refill
- +1 every 2s (~1,800/hr)
- Videos - burst
- 20
- Videos - refill
- +1 every 90s (~40/hr)
Video limits are intentionally capped near total processing capacity, so they won't move much even on higher tiers. Exceeding your limit returns 429 with a Retry-After header (seconds to wait) plus X-RateLimit-Limit and X-RateLimit-Remaining. A 429 is checked before credits are deducted, so a throttled request never costs you a credit.
Errors
Every non-2xx response is JSON
Error bodies always look like this - check the status code before treating the response body as a result:
{
"detail": "Insufficient credits"
}| Status | Meaning | Example detail |
|---|---|---|
| 400 | Bad request - not an image, or the file couldn't be decoded. | "File must be an image" |
| 401 | API key missing, malformed, or revoked. | "Invalid or revoked API key" |
| 402 | Not enough credits for this call. | "Insufficient credits" |
| 404 | Job doesn't exist, or belongs to a different key. | "Job not found" |
| 409 | You called /result before the job reached "done". | "Job not ready (status: processing)" |
| 410 | Result already fetched once, or its TTL expired. | "Result expired or already retrieved" |
| 429 | Rate limit reached for your tier - see Retry-After. | "Rate limit reached for your free plan..." |
| 500 | Unexpected server error during redaction. | "Redaction failed: ..." |
- Status
- 400
- Meaning
- Bad request - not an image, or the file couldn't be decoded.
- Example detail
- "File must be an image"
- Status
- 401
- Meaning
- API key missing, malformed, or revoked.
- Example detail
- "Invalid or revoked API key"
- Status
- 402
- Meaning
- Not enough credits for this call.
- Example detail
- "Insufficient credits"
- Status
- 404
- Meaning
- Job doesn't exist, or belongs to a different key.
- Example detail
- "Job not found"
- Status
- 409
- Meaning
- You called /result before the job reached "done".
- Example detail
- "Job not ready (status: processing)"
- Status
- 410
- Meaning
- Result already fetched once, or its TTL expired.
- Example detail
- "Result expired or already retrieved"
- Status
- 429
- Meaning
- Rate limit reached for your tier - see Retry-After.
- Example detail
- "Rate limit reached for your free plan..."
- Status
- 500
- Meaning
- Unexpected server error during redaction.
- Example detail
- "Redaction failed: ..."
Credits
Shared with the Scanon web app
- API calls draw from the same credit balance as the web app - there's no separate API pool.
- Images cost 1 credit, or 2 credits when
encryption_mode="cryptographic". - Videos cost 1 credit, or 2 credits when
encryption_mode="cryptographic". - Credits are deducted atomically before processing starts. If an image redaction fails unexpectedly (500), the credits charged for that call are automatically refunded - 2 for cryptographic mode, 1 otherwise.
- A 402 (insufficient credits) or 429 (rate limited) never deducts a credit.
Result lifecycle (video)
Single fetch, then it's gone
- Once a job reaches
status: "done", the redacted video is held for 10 minutes. GET /v1/videos/{job_id}/resultdeletes the cached file immediately after it streams the response - fetch it once and save it. A second call (or a call after the 10-minute window) returns 410.- Jobs that go more than ~2 minutes without a status poll are treated as abandoned and cancelled - keep polling every few seconds until you see a terminal status.
Need something beyond faces, plates, and screens?
We build custom detection pipelines tailored to your use case - contact us to discuss what you're trying to redact.
Contact us