SafeReel API — v1 Reference

Base URL: https://api.safereel.ai (production). During evaluation you may be given a sandbox URL instead — use exactly what we send you. Interactive OpenAPI docs are also available at /swagger (Swagger UI) and /openapi.json on a running instance. The canonical, versioned OpenAPI spec lives at github.com/safereel/openapi (CI-checked against the live API) — import it into Postman/Insomnia or generate clients from it.

Official SDKs

Thin open-source clients (MIT) covering image checks, video submit + poll, webhook verification, typed errors, and automatic retries:

CLI

The Python package also installs a safereel command for scripting and agent use (no code required):

safereel check frame.jpg                 # image verdict as JSON
safereel submit https://cdn.example.com/movie.mp4 --wait
safereel poll vid_52271e017ebc44e6
safereel status                          # no API key needed

Reads SAFEREEL_API_KEY / SAFEREEL_BASE_URL from the environment; exits non-zero with a stderr message on API or network errors.

Integration guides

Task-oriented walkthroughs with complete, runnable code:

Browser warning: never call this API from browser JavaScript on a public page — it exposes your API key. The playground and dashboard are the only sanctioned browser flows. CORS is disabled for browser origins by default.

All errors share one shape:

{"error": {"code": "invalid_key", "message": "unknown or disabled API key", "retry_after": null}}

Error codes: invalid_request (400) · invalid_key (401) · quota_exceeded (402) · turnstile_failed (403) · not_found (404) · idempotency_conflict (409) · payload_too_large (413) · unsupported_media (415) · rate_limited / demo_rate_limited / demo_at_capacity (429) · processing_failed / submit_failed / email_failed (500) · service_starting / inference_unavailable (503).

On 503 inference_unavailable (capacity momentarily full): retry with exponential backoff starting at 1s (max ~30s); it clears as soon as a worker is free. On 429 rate_limited: wait retry_after seconds.

Rate-limit headers: rate-limited endpoints report their state in RFC 9449-style response headers so clients can self-throttle before hitting a 429:

Pricing (reference): plans €19–€399/mo with included allowances, or PAYG with no monthly commitment: €0.020/video-minute (billed per second, capped at 20 billed minutes per video) + €0.50/1k images, invoiced monthly in arrears. Charged usage is only ever real inference — cache hits and failed jobs are free.


Authentication

Every endpoint except GET /v1/status and POST /v1/demo/check requires a bearer key:

Authorization: Bearer sr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Get a key

The fastest path is the dashboard: sign in at /dash (magic link, no password) and create a key under API keys — it's shown exactly once. There's also a zero-friction form on the homepage: enter your email, click the link we send, and your key is issued on the spot.

Programmatic signup exists for integrations (POST /v1/signup → GET /v1/verify), but it is not part of the stable public contract — prefer the dashboard unless you specifically need it.

Free tier: 1,000 images/month, 25 videos AND 100 video-minutes/month (max 10 min per video), 1 req/s. Paid tiers raise all of these.

Tiers and limits

Free Pro
Images (/v1/check) 1 req/s, 500/day, 1k/month 10 req/s, 40k/day, 100k/month
Videos (/v1/videos) 25/month AND 100 video-minutes/month, max 10 min per video 20k/month AND 10k video-minutes/month, max 4 h per video

Starter/Basic/Scale sit between Free and Pro (see the pricing page). On PAYG the same shape applies with high abuse ceilings instead of included allowances; usage is metered per second for video (20-minute billing cap per video) and per check for images — cached checks are never billed.


Images: POST /v1/check

Classify one image. Synchronous; median ~150–250ms on cache miss, 0ms on hit.

curl -X POST €BASE/v1/check \
    -H "Authorization: Bearer €KEY" \
    -H "Content-Type: image/jpeg" \
    --data-binary @frame.jpg
{
  "id": "chk_27a68debe90e1c57",
  "verdict": "clean",            // "clean" | "nsfw"
  "confidence": 1.0,
  "model": "safereel-v1",
  "cached": false,
  "processing_ms": 141
}

Errors: 400 invalid_request · 401 invalid_key · 402 quota_exceeded · 413 payload_too_large · 415 unsupported_media (wrong type or undecodable) · 429 rate_limited · 500 processing_failed · 503 service_starting / 503 inference_unavailable (capacity momentarily full — retry shortly).

Python:

import httpx

BASE, KEY = "https://api.safereel.ai", "sr_live_…"

with open("frame.jpg", "rb") as f:
    resp = httpx.post(
        f"{BASE}/v1/check",
        headers={"Authorization": f"Bearer {KEY}", "Content-Type": "image/jpeg"},
        content=f.read(),
        timeout=30,
    )
resp.raise_for_status()
print(resp.json()["verdict"])

Anonymous demo: POST /v1/demo/check

The public, no-signup image demo that powers the playground. Same response shape as /v1/check.


Videos: POST /v1/videos → GET /v1/videos/{id}

Classify a video by source URL — you never upload bytes. Give us any public http(s) URL (your CDN, S3, a direct file link). We analyze the full duration of the file and return a single verdict.

Submit

curl -X POST €BASE/v1/videos \
    -H "Authorization: Bearer €KEY" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://cdn.example.com/movie.mp4",
         "webhook_url": "https://you.example.com/safereel-hook"}'
{
  "id": "vid_52271e017ebc44e6",
  "status": "queued",
  "webhook_secret": "7bba1544c9ed359ac8195cb0eeaa9e6f"
}

Poll

curl €BASE/v1/videos/vid_52271e017ebc44e6 -H "Authorization: Bearer €KEY"
{
  "id": "vid_52271e017ebc44e6",
  "status": "done",        // queued | processing | done | failed
  "verdict": "nsfw",       // null until done
  "frames": 93,            // frames sampled
  "flagged": 93,           // frames flagged
  "flagged_ts": [10.5, 42.0],  // timestamps (s) of flagged frames, null until done
  "duration_s": 30.011,
  "error": null
}

Job ids are owner-only — another key gets 404 not_found, same as unknown ids.

Webhook

If you passed webhook_url, we POST the same body as the poll response on completion. Delivery: up to 4 attempts (immediate, then after 2s, 10s, 30s), 10s timeout per attempt; anything other than a 2xx response is a failure. Verify the signature with your webhook_secret (signing covers the exact raw body — check it before parsing):

import hashlib, hmac

expected = hmac.new(webhook_secret.encode(), raw_body, hashlib.sha256).hexdigest()
assert hmac.compare_digest(f"sha256={expected}", request.headers["X-SafeReel-Signature"])

The signature is over the exact request body — compute it on the raw bytes, not a re-serialized JSON object.


Status: GET /v1/status

Public, no auth. Powers the status page; poll no more than every 30s.

{"status": "ok", "load": "normal", "version": "0.1.0", "uptime_s": 3600}

load is one of normal / elevated / unavailable / starting.


Versioning & deprecation


Content handling