Moderate S3 or R2-hosted videos with SafeReel

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.

Prerequisites

Install the dependencies:

npm install @safereel/sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner express

Step 1 — Generate a presigned GET URL

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:

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 });
}

Step 2 — Submit the video to SafeReel

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.

Step 3 — Receive and verify the webhook

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.

Step 4 — Apply the moderation decision

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.

Errors and edge cases

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.

Next steps

Need an API key? Get one free at safereel.ai — 1,000 image checks and 100 video-minutes per month, no card required.