Build a reliable video moderation pipeline

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.

Prerequisites

Submit with an Idempotency-Key

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:

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.

Receive webhooks (primary path)

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:

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

Keep polling as a fallback

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.

Complete worker loop

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

Errors and edge cases

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.

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.