This guide wires up a complete pipeline: generate a presigned GET URL for a video in S3 or Cloudflare R2, submit it to SafeReel, receive the signed webhook, and apply a block/publish/review decision. You never move bytes through your own server — SafeReel fetches the object directly from storage.
SAFEREEL_API_KEY.s3:GetObject on the bucket (works for R2 too, which is S3-compatible).ngrok.Install the dependencies:
npm install @safereel/sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner express
SafeReel downloads the video from the URL you give it, so the URL must stay valid for the whole job. Presign with an expiry comfortably longer than your worst-case processing time — processing scales with video duration, and queue delay adds on top. One hour is a sane default for typical uploads; use more for long files.
Two hard requirements on the object:
failed.import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: "auto", // R2 uses "auto"; use your real region for AWS S3
endpoint: process.env.S3_ENDPOINT, // e.g. https://<accountid>.r2.cloudflarestorage.com; omit for AWS
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function presignVideo(key: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
});
// Expiry must outlive the longest job you expect. 1 hour here.
return getSignedUrl(s3, command, { expiresIn: 3600 });
}
POST the presigned URL to /v1/videos with your webhook URL. The SDK's submitVideo does this and automatically attaches an Idempotency-Key header (a random UUID), so a retry after a network error replays the original submission instead of creating a second, billable job.
import { SafeReel } from "@safereel/sdk";
const safereel = new SafeReel(); // reads SAFEREEL_API_KEY from the environment
export async function submitForModeration(key: string) {
const url = await presignVideo(key);
const submission = await safereel.submitVideo(url, {
webhookUrl: "https://you.example.com/safereel-hook",
// Optional: pass a stable key (e.g. derived from the object key) to
// deduplicate across process restarts. Without it, resubmitting the
// same URL after a restart creates a new job.
// idempotencyKey: `s3:${key}`,
});
// webhook_secret is returned once, at submit time. Persist it with the
// job id — you need it to verify the webhook signature later.
if (!submission.webhook_secret) {
throw new Error("expected a webhook_secret when webhookUrl is set");
}
await storeJob(submission.id, key, submission.webhook_secret);
return submission.id;
}
// Replace with your database.
async function storeJob(jobId: string, key: string, webhookSecret: string) {
console.log(`tracking job ${jobId} for ${key}`);
}
Submission returns immediately with status: "queued". The video counts against your monthly quota at submit time, so exhausted quota fails here with a 402 quota_exceeded (QuotaExceededError in the SDK) — not later.
When the job finishes, SafeReel POSTs the result to your webhook URL with an X-SafeReel-Signature: sha256=<hex> header. The HMAC-SHA256 covers the exact raw request body, keyed with your webhook_secret. Verify before parsing, and verify the raw bytes — a re-serialized JSON object will not match.
With Express, that means express.raw(), not express.json():
import express from "express";
import { verifyWebhookSignature } from "@safereel/sdk";
const app = express();
app.post(
"/safereel-hook",
express.raw({ type: "application/json" }), // keep the raw body bytes
async (req, res) => {
const jobId = JSON.parse(req.body.toString("utf8")).id as string;
const secret = await lookupWebhookSecret(jobId); // from your database
if (!secret) return res.sendStatus(404);
const ok = verifyWebhookSignature(
req.body, // Buffer of the exact request bytes
req.header("X-SafeReel-Signature"),
secret,
);
if (!ok) return res.sendStatus(401); // untrusted request
const job = JSON.parse(req.body.toString("utf8"));
await applyDecision(job);
res.sendStatus(200);
},
);
async function lookupWebhookSecret(jobId: string): Promise<string | null> {
return null; // your lookup here
}
The webhook body is the same object GET /v1/videos/{id} returns:
{
"id": "vid_52271e017ebc44e6",
"status": "done",
"verdict": "nsfw",
"frames": 93,
"flagged": 93,
"duration_s": 30.011,
"error": null
}
Delivery is retried up to 4 times (immediate, then after 2s, 10s, and 30s) with a 10s timeout per attempt. Anything other than a 2xx counts as a failure, so return 200 only after you have durably handled the event. Deliveries can repeat — key your handler on id and treat re-delivery of a completed job as a no-op.
status is done or failed. verdict is clean or nsfw and is only meaningful on done — it is null otherwise.
type Decision = "publish" | "block" | "review";
function decide(job: {
status: string;
verdict: string | null;
frames: number;
flagged: number;
}): Decision {
if (job.status === "failed") return "review"; // never auto-publish a failed scan
if (job.verdict === "nsfw") return "block";
return "publish";
}
async function applyDecision(job: {
id: string;
status: string;
verdict: string | null;
frames: number;
flagged: number;
error: string | null;
}) {
const decision = decide(job);
console.log(`job ${job.id}: ${job.status}/${job.verdict} -> ${decision}`);
// Enforce it in your own system: delete the object, unpublish, or flag
// for a human moderator. SafeReel returns the verdict; enforcement is yours.
}
Map the decision to whatever your platform does: delete or quarantine the S3 object on block, mark the listing live on publish, queue a human review on review. For measured classifier behavior — including the deliberate boundary cases (non-explicit nudity, medical/artistic context) — see the accuracy report at https://safereel.ai/accuracy.
failed. Usually a fetch problem: presigned URL expired before SafeReel got to the object, wrong region or credentials baked into the signature, or the object was deleted after submit. The error field carries a generic message; check your presign expiry and bucket access first. Failed jobs are not billed. Resubmit with a fresh URL and, if you passed your own idempotencyKey, a fresh key — the same key with a different body returns 409 idempotency_conflict.expiresIn before anything else.429 rate_limited (honoring retry_after), 503, and transport errors automatically. Retrying submitVideo is safe because of the automatic Idempotency-Key.GET /v1/videos/{id} via client.getVideo(job.id) as a fallback. Job ids are owner-only — a different API key gets 404.The Python SDK supports the same flow: client.submit_video(url, webhook_url=...) plus safereel.verify_webhook_signature(...) for the receiver. See the SDK repo for a Flask example.
Need an API key? Get one free at safereel.ai — 1,000 image checks and 100 video-minutes per month, no card required.