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.
Thin open-source clients (MIT) covering image checks, video submit + poll, webhook verification, typed errors, and automatic retries:
npm install @safereel/sdk ·
github.com/safereel/safereel-nodepip install safereel ·
github.com/safereel/safereel-pythongo get github.com/safereel/safereel-go ·
github.com/safereel/safereel-goThe 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.
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:
RateLimit-Limit — the allowance (/v1/check: requests/second for your
tier; /v1/demo/check: images/day per IP)RateLimit-Remaining — requests left in the current windowRateLimit-Reset — seconds until the window refillsRetry-After — on every 429, seconds to wait (mirrors retry_after
in the error body)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.
Every endpoint except GET /v1/status and POST /v1/demo/check requires a
bearer key:
Authorization: Bearer sr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
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.
| 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.
429 rate_limited (retry_after in the error body).402 quota_exceeded.POST /v1/checkClassify one image. Synchronous; median ~150–250ms on cache miss, 0ms on hit.
image/jpeg, image/png, or image/webp. Max 10MB.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
}
verdict is nsfw for pornographic content. Non-explicit nudity,
swimwear, and medical or artistic contexts are out of scope — see the
accuracy report for measured behavior.confidence is currently 1.0/0.0 — verdicts are deterministic and
boolean today; treat confidence as informational.model is a product version code (safereel-v1), bumped when the
production classifier changes — the underlying stack is not disclosed.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"])
POST /v1/demo/checkThe public, no-signup image demo that powers the playground. Same response
shape as /v1/check.
X-Turnstile-Token: <token> (a
human-verification token; missing/invalid → 403 turnstile_failed).429 demo_rate_limited), a global
429 demo_at_capacity limit when the free demo is saturated, 5MB max body, jpeg/png/webp only.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.
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"
}
webhook_url optional. webhook_secret is returned at submit time and signs the
webhook (below). Duration limits: free tier 10 minutes per video,
paid tiers 4 hours. URLs are validated (no redirects to private addresses);
fetch errors fail the job with a generic message. Billing note: every
video bills at most 20 minutes regardless of length.402 quota_exceeded.
Idempotent retries: send an Idempotency-Key: <unique string> header
(1–255 chars, e.g. a UUID) to make submission safe to retry — a retry with
the same key and the same body replays the original job's submit response
(same id, same webhook_secret) instead of creating a duplicate;
reusing the key with a different body returns 409 idempotency_conflict.
Without the header, resubmitting the same URL creates a new job.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.
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.
GET /v1/statusPublic, 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.
/v1. The current version is v1./v2), never as a silent change
to /v1. A sunset version keeps serving for at least 90 days after
its replacement is announced.Deprecation response header
(RFC 9745) and a Sunset header (RFC 8594, HTTP-date of planned
removal) on affected endpoints, an announcement email to the address on
the account, and a notice in these docs.POST /v1/signup, GET /v1/verify,
the dashboard — are not part of the stable contract and may change
without notice.