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.
curl -X POST https://api.scanon.ai/v1/images \ -H "Authorization: Bearer $SCANON_KEY" \ -F "file=@photo.jpg" \ -F "detect_faces=true"
What the API does
The Scanon API detects and redacts personally identifiable information - faces, license plates, and screens - in images and video, and sensitive data - SSNs, credit card and bank routing numbers, IBANs, emails, and phone numbers - in PDF documents. Send it a file, get back a redacted one. 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
1POST /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"2GET /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
fi3GET /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.
Documents - asynchronous
Submit, poll, then fetch the result - PDF, DOCX, PPTX, XLSX, ODT, TXT, or MD
Detect and redact are separate endpoints - POST /v1/documents/detect returns findings only and never modifies the file; POST /v1/documents/redact returns findings plus a redacted file to fetch. Both accept the same file + policy multipart body shown below.
1POST /v1/documents/detect | redact
Accepts file (multipart, required - PDF, DOCX, PPTX, XLSX, ODT, TXT, or MD, max 25MB; non-PDF formats are converted to PDF server-side before detection/redaction runs) and policy (form string, optional JSON). Returns {"job_id": "...", "status": "processing", "page_count": N} immediately - page_count is also how many document credits were just deducted, since documents are billed per page, not per file.
# Requires jq (https://jqlang.org) for JSON parsing.
API_KEY="scanon_your_api_key"
# policy is a JSON string sent as a regular form field alongside the file.
# entities/per_entity/page_range/strip_metadata are all optional - omitting
# policy entirely scans every supported entity type on every page.
POLICY='{
"entities": ["ssn", "credit_card", "email", "phone"],
"per_entity": {
"ssn": { "action": "redact" },
"credit_card": { "action": "mask" }
},
"page_range": [1, 10],
"strip_metadata": true,
"issue_certificate":true
}'
# Detect only - findings back, file untouched:
# POST https://api.scanon.ai/v1/documents/detect
JOB=$(curl -s -X POST https://api.scanon.ai/v1/documents/redact \
-H "Authorization: Bearer $API_KEY" \
-F "file=@statement.pdf" \
-F "policy=$POLICY")
JOB_ID=$(echo "$JOB" | jq -r '.job_id')
echo "Submitted job $JOB_ID"2GET /v1/documents/{job_id}
Returns status (processing → done/failed/cancelled), and once done, the manifest - entity counts, a per-page breakdown, and each finding's page, bounding box, confidence, and which recognizer fired. The matched sensitive value itself is never included anywhere in this response. For a redact job this also includes doc_id (the document ID stamped into the redaction certificate), signed (whether a certificate was successfully generated and signed), and signing_reason (set when signed is not true, explaining why). If the request didn't set issue_certificate: true, no certificate was requested - doc_id and signed are both null, with signing_reason: "not_requested".
# 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/documents/$JOB_ID" -H "Authorization: Bearer $API_KEY")
STATUS=$(echo "$POLL" | jq -r '.status')
echo "status=$STATUS"
done
if [ "$STATUS" != "done" ]; then
echo "Job did not complete: $(echo "$POLL" | jq -r '.error')"
exit 1
fi
# The manifest (entity counts, per-page breakdown, bboxes, confidence) is
# embedded in this same response once status is "done" - never the matched
# values themselves, only where and what type was found.
echo "$POLL" | jq '.manifest'3GET /v1/documents/{job_id}/result
Returns the redacted document as raw application/pdf bytes - redact jobs only. A detect job has no file to fetch and this returns 409. See Result lifecycle below before you call it.
# Redact jobs only - detect jobs never modify the file and return 409 here.
# Single fetch only - a second call to /result returns 410
HTTP_STATUS=$(curl -s -o redacted.pdf -w "%{http_code}" \
"https://api.scanon.ai/v1/documents/$JOB_ID/result" \
-H "Authorization: Bearer $API_KEY")
if [ "$HTTP_STATUS" -eq 200 ]; then
echo "Saved redacted.pdf"
else
echo "Fetch failed ($HTTP_STATUS) - result may have expired, already been retrieved, or this was a detect-only job"
exit 1
fiRedaction certificate
A redact job can append one or more certificate pages to the end of the PDF returned by GET /v1/documents/{job_id}/result. The certificate lists the document ID, generation timestamp, a QR code and link to the public verify page, a per-category count of the regions removed, and the hashing/signing method used - it does not disclose the redacted content itself. The stamped file is hashed and signed after the certificate is added, so the certificate is covered by the same signature as the rest of the document.
Certificates are opt-in on this API and off by default - set issue_certificate: true in policy to get one. Omit it, or set it to false, and the redacted file comes back with no certificate pages.
Certificates generated through the API carry a small attribution line near the foot of the page - "Redacted using scanon API" - distinguishing them from certificates produced by the scanon web app. Anyone with the PDF or its QR code can confirm authenticity at the verify URL without needing API access themselves.
Policy
Every field is optional - an omitted policy scans every supported entity on every page with the default action (redact).
{
"entities": ["ssn", "credit_card", "email", "phone"],
"per_entity": {
"ssn": { "action": "redact" },
"credit_card": { "action": "mask" }
},
"page_range": [1, 10],
"strip_metadata": true,
"issue_certificate":true
}entities- array ofssn,credit_card,aba_routing,iban,email,phone.per_entity- per-typethreshold(0-1, default 0.75) andaction(redact|mask|hash|label|none). Onlyredactphysically removes the underlying text; the others still detect but leave it in place or replace it with a masked/hashed/labeled overlay.page_range-[start, end], 1-indexed inclusive. Omit to scan every page.strip_metadata- boolean, defaulttrue. Redact jobs only.issue_certificate- boolean, defaultfalse. Redact jobs only - settrueto receive a redaction certificate appended to the result.
Note: DOCX, PPTX, XLSX, and ODT uploads are converted to PDF before detection runs - an embedded image (e.g. a scanned page pasted into a Word doc) is still just pixels once converted, so it's evaluated exactly like a scanned PDF page: if OCR can't read it, that page is flagged ocr_unavailable_pages in the manifest rather than silently reporting zero findings.
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 | Documents - burst | Documents - refill |
|---|---|---|---|---|---|---|
| free | 20 | +1 every 30s (~120/hr) | 2 | +1 every 15 min (~4/hr) | 10 | +1 every 60s (~60/hr) |
| pro | 100 | +1 every 5s (~720/hr) | 10 | +1 every 3 min (~20/hr) | 50 | +1 every 10s (~360/hr) |
| team | 300 | +1 every 2s (~1,800/hr) | 20 | +1 every 90s (~40/hr) | 150 | +1 every 4s (~900/hr) |
- Tier
- free
- Images - burst
- 20
- Images - refill
- +1 every 30s (~120/hr)
- Videos - burst
- 2
- Videos - refill
- +1 every 15 min (~4/hr)
- Documents - burst
- 10
- Documents - refill
- +1 every 60s (~60/hr)
- Tier
- pro
- Images - burst
- 100
- Images - refill
- +1 every 5s (~720/hr)
- Videos - burst
- 10
- Videos - refill
- +1 every 3 min (~20/hr)
- Documents - burst
- 50
- Documents - refill
- +1 every 10s (~360/hr)
- Tier
- team
- Images - burst
- 300
- Images - refill
- +1 every 2s (~1,800/hr)
- Videos - burst
- 20
- Videos - refill
- +1 every 90s (~40/hr)
- Documents - burst
- 150
- Documents - refill
- +1 every 4s (~900/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" |
| 413 | Document exceeds the 25MB upload limit. | "Document exceeds maximum upload size (25MB)" |
| 415 | Unsupported file type - see supported formats above. | "Unsupported file type '.zip'. Supported: .docx, .md, .odt, .pdf, .pptx, .txt, .xlsx" |
| 422 | DOCX/PPTX/XLSX/ODT/TXT/MD upload that failed to convert to PDF (corrupt file, conversion error). | "Could not convert document to PDF: ..." |
| 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
- 413
- Meaning
- Document exceeds the 25MB upload limit.
- Example detail
- "Document exceeds maximum upload size (25MB)"
- Status
- 415
- Meaning
- Unsupported file type - see supported formats above.
- Example detail
- "Unsupported file type '.zip'. Supported: .docx, .md, .odt, .pdf, .pptx, .txt, .xlsx"
- Status
- 422
- Meaning
- DOCX/PPTX/XLSX/ODT/TXT/MD upload that failed to convert to PDF.
- Example detail
- "Could not convert document to PDF: ..."
- 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.
- Documents are billed by page, not by file -
page_countdocument credits are deducted per call (a 10-page PDF costs 10 credits, detect or redact). Page count is only known once the file is opened, so credits are deducted right after that, before detection runs - and refunded in full if the job fails. - A 402 (insufficient credits) or 429 (rate limited) never deducts a credit.
Result lifecycle (video & documents)
Single fetch, then it's gone
- Once a job reaches
status: "done", the redacted video or document is held for 10 minutes. GET /v1/videos/{job_id}/resultandGET /v1/documents/{job_id}/resultdelete 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.- A document
detectjob never has a file to begin with - calling/resulton one returns 409 regardless of TTL. - 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