← SafeReel · OpenAPI reference (Swagger) · Accuracy report
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 /docs (Swagger UI) and
/openapi.json on a running instance.
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) · 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.
Pricing (reference): $0.020/video-minute PAYG (20-minute billing cap per video) and $0.50/1k images past the free tier; plans $19–$399/mo. 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, 50k/day, 1M/month |
Videos (/v1/videos) |
25/month AND 100 video-minutes/month, max 10 min per video | 100k/month |
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 once 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.
Resubmitting the same URL creates a new job (video submits are not
idempotent); you are never billed twice for identical content.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
"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.