API REFERENCE

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"
API REFERENCE

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_key

Keys 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
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
fi

Videos - 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
fi

3GET /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
fi

Note: 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
fi

Redaction 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 of ssn, credit_card, aba_routing, iban, email, phone.
  • per_entity - per-type threshold (0-1, default 0.75) and action (redact | mask | hash | label | none). Only redact physically 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, default true. Redact jobs only.
  • issue_certificate - boolean, default false. Redact jobs only - set true to 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
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
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_count document 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}/result and GET /v1/documents/{job_id}/result delete 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 detect job never has a file to begin with - calling /result on 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