Video scanning on SafeReel is asynchronous: you submit a public URL, the job runs on our workers, and you learn the verdict via webhook or polling. This guide walks through a production-grade Node.js worker that submits URLs safely, receives signed webhooks idempotently, falls back to polling, and survives every failure mode the API can produce.
fetch).SAFEREEL_API_KEY — the SDK reads it automatically. Free tier includes 100 video-minutes/month.npm install @safereel/sdk (github.com/safereel/safereel-node).POST /v1/videos charges against your monthly video quota at submit time, so a retried submit can create a duplicate, billable job — unless you send an Idempotency-Key header (1–255 chars). The guarantee:
id, same webhook_secret). No duplicate job.409 idempotency_conflict.The SDK sends a random UUID as the key automatically, which covers retries after network errors. If you need deduplication across process restarts, derive your own key from the URL and pass it as idempotencyKey:
import { createHash } from "node:crypto";
import { SafeReel } from "@safereel/sdk";
const client = new SafeReel(); // reads SAFEREEL_API_KEY from the environment
const url = "https://cdn.example.com/movie.mp4";
const idempotencyKey = createHash("sha256").update(url).digest("hex");
const submission = await client.submitVideo(url, {
webhookUrl: "https://you.example.com/safereel-hook",
idempotencyKey,
});
console.log(submission.id); // "vid_..."
webhook_secret is only returned in the submit response. Persist it alongside the job id — you need it to verify webhook signatures later, and you cannot retrieve it again.
If you passed webhookUrl, the API POSTs the final job object — the same body as the poll response — on completion, with an X-SafeReel-Signature: sha256=<hex> header. The HMAC-SHA256 is computed with your webhook_secret over the exact raw request body. Verify before parsing, never on a re-serialized JSON object. With Express, express.raw() keeps the bytes intact:
import express from "express";
import { verifyWebhookSignature } from "@safereel/sdk";
// In production, load this from the row you wrote at submit time.
const webhookSecrets = new Map<string, string>();
const app = express();
app.post(
"/safereel-hook",
express.raw({ type: "application/json" }), // keep the raw body
(req, res) => {
const raw = req.body as Buffer;
const signature = req.header("X-SafeReel-Signature");
// We key secrets by job id, which requires a parse — do a throwaway
// parse only to *look up* the secret, then verify on the raw bytes.
const jobId: string | undefined = JSON.parse(raw.toString("utf8"))?.id;
const secret = jobId ? webhookSecrets.get(jobId) : undefined;
if (!secret || !verifyWebhookSignature(raw, signature, secret)) {
return res.sendStatus(401); // untrusted — do not process
}
const job = JSON.parse(raw.toString("utf8"));
handleJob(job); // idempotent — see below
res.sendStatus(200);
},
);
Delivery is retried up to 4 times: immediately, then after 2s, 10s, and 30s, with a 10s timeout per attempt. Anything other than a 2xx counts as a failure. Two consequences:
processed flag before acting:import type { VideoJob } from "@safereel/sdk";
const processed = new Set<string>();
function storeVerdict(id: string, verdict: string | null, flagged: number, frames: number): void {
// Replace with an idempotent upsert into your database.
console.log(`store: ${id} verdict=${verdict} flagged=${flagged}/${frames}`);
}
function handleJob(job: VideoJob): void {
if (processed.has(job.id)) return; // duplicate delivery
processed.add(job.id);
if (job.status === "failed") {
console.error(`job ${job.id} failed: ${job.error}`);
return;
}
storeVerdict(job.id, job.verdict, job.flagged, job.frames);
}
Webhooks can be lost if your endpoint is down through all four attempts. For anything that matters, reconcile with polling. waitForVerdict polls GET /v1/videos/{id} until the job reaches done or failed:
import { PollingTimeoutError } from "@safereel/sdk";
try {
const job = await client.waitForVerdict(submission.id, {
timeoutMs: 15 * 60_000, // default is 10 minutes
});
console.log(job.status, job.verdict);
} catch (err) {
if (err instanceof PollingTimeoutError) {
// Job is still queued/processing server-side. Re-check later with
// client.getVideo(id) — do not resubmit.
} else {
throw err;
}
}
Do not poll faster than roughly every 2 seconds. The SDK's poller starts at 2s and backs off to 5s with jitter, so its defaults are already polite. A PollingTimeoutError means the job is still running on our side — it is not a failure, and resubmitting creates a second, billable job.
A queue consumer that submits URLs, waits for verdicts, stores results, and degrades gracefully. Swap the Map for your database; the control flow stays the same.
import { createHash } from "node:crypto";
import {
SafeReel,
SafeReelError,
IdempotencyConflictError,
PollingTimeoutError,
QuotaExceededError,
} from "@safereel/sdk";
const client = new SafeReel();
interface StoredVerdict {
jobId: string;
url: string;
status: "done" | "failed";
verdict: "clean" | "nsfw" | null;
error: string | null;
}
const store = new Map<string, StoredVerdict>(); // stand-in for your DB
async function processUrl(url: string): Promise<StoredVerdict> {
// Stable key: a crash-and-retry of this URL replays, never duplicates.
const idempotencyKey = createHash("sha256").update(url).digest("hex");
let jobId: string;
try {
const submission = await client.submitVideo(url, { idempotencyKey });
jobId = submission.id;
} catch (err) {
if (err instanceof IdempotencyConflictError) {
// Same key, different body: our key derivation is broken. Loud failure.
throw new Error(`idempotency conflict submitting ${url}`, { cause: err });
}
if (err instanceof QuotaExceededError) {
// Plan exhausted. Pause the queue; do not hammer the API.
throw new Error("video quota exhausted — stop the worker and alert", { cause: err });
}
throw err; // 400/401/etc. — a bug or a bad URL, surface it
}
try {
const job = await client.waitForVerdict(jobId, { timeoutMs: 30 * 60_000 });
const record: StoredVerdict = {
jobId: job.id,
url,
status: job.status as "done" | "failed",
verdict: job.verdict,
error: job.error,
};
store.set(url, record);
return record;
} catch (err) {
if (err instanceof PollingTimeoutError) {
// Unknown outcome. Persist jobId and reconcile later via getVideo —
// the job still exists and will finish server-side.
console.error(`timeout waiting on ${jobId} (${url}); reconcile later`);
throw err;
}
if (err instanceof SafeReelError) {
// After the SDK's automatic retries are exhausted, 429/503/transport
// errors land here. Safe to re-enqueue: the submit was idempotent.
console.error(`transient failure on ${url}: ${err.code} (HTTP ${err.httpStatus})`);
}
throw err;
}
}
async function workerLoop(queue: string[]): Promise<void> {
for (const url of queue) {
try {
const record = await processUrl(url);
console.log(`${url} -> ${record.status} ${record.verdict ?? record.error ?? ""}`);
} catch (err) {
// Already logged with context in processUrl; keep the queue moving.
console.error(`giving up on ${url}:`, err instanceof Error ? err.message : err);
}
}
}
const queue = [
"https://cdn.example.com/a.mp4",
"https://cdn.example.com/b.mp4",
];
await workerLoop(queue);
console.log(`stored ${store.size} verdicts`);
All API errors share one shape — {"error": {"code", "message", "retry_after"}} — and the SDK maps them onto typed errors under SafeReelError, each carrying httpStatus, code, and retryAfter. The full code list is in the API reference.
429 rate_limited and 503 inference_unavailable are backpressure, not outages. 429 means slow down (the body carries retry_after seconds); 503 means inference capacity is momentarily full and clears when a worker frees up. The SDK retries both automatically (honoring retry_after, exponential backoff from 1s capped at 30s, 3 attempts by default) plus transport errors. Only if all attempts fail do they reach your catch — re-enqueue the item rather than crashing the worker.quota_exceeded as a stop-the-line signal, 401 invalid_key as a config bug, and 404 not_found as real — job ids are owner-only, so another key sees 404 even for jobs that exist.status: "failed" is a normal outcome. Unfetchable URLs, undecodable files, and over-limit durations (free tier: 10 min/video; paid: 4 h) fail the job with a generic message in error. verdict is null; failed jobs are not billed. Videos bill at most 20 minutes regardless of length.verdict is "clean" or "nsfw" for pornographic content; non-explicit nudity, swimwear, and medical/artistic contexts are out of scope. See the measured behavior at safereel.ai/accuracy before setting enforcement thresholds.Need an API key? Get one free at safereel.ai — 1,000 image checks and 100 video-minutes per month, no card required.