Moderate image uploads in Node.js with SafeReel

You will build an Express endpoint that accepts a user-uploaded image, screens it with SafeReel's /v1/check API, and returns an allow/block decision before the file ever touches your storage. A compact Next.js App Router variant is included at the end.

Prerequisites

export SAFEREEL_API_KEY=sr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
npm install @safereel/sdk express multer

Keep the call server-side

Never call the SafeReel API from browser JavaScript. The API key would be visible to anyone who opens devtools, and the API refuses browser origins anyway — CORS is disabled. The correct topology is: browser uploads to your server, your server calls SafeReel, your server returns the decision.

The call is fast enough to make inline. Image classification is synchronous: median ~150–250ms on a cache miss, 0ms on a hit. That is well inside a normal upload request budget, so you do not need a queue or a webhook for images.

Express route

The route below accepts a multipart upload with multer's memoryStorage, validates the content type and size, screens the bytes with checkImage, and returns allow/block. Only on allow would you persist the file to disk or object storage.

import express from "express";
import multer from "multer";
import {
  SafeReel,
  QuotaExceededError,
  RateLimitError,
  SafeReelError,
} from "@safereel/sdk";

const client = new SafeReel(); // reads SAFEREEL_API_KEY from the environment

const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX_BYTES = 10 * 1024 * 1024; // /v1/check hard limit

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: MAX_BYTES },
  fileFilter: (_req, file, cb) => {
    // Reject anything that is not jpeg/png/webp; req.file will be undefined.
    cb(null, ALLOWED_TYPES.has(file.mimetype));
  },
});

const app = express();

app.post("/upload", upload.single("image"), async (req, res) => {
  if (!req.file) {
    return res
      .status(415)
      .json({ error: "an image/jpeg, image/png or image/webp file is required" });
  }

  try {
    const result = await client.checkImage(req.file.buffer, req.file.mimetype);

    if (result.verdict === "nsfw") {
      return res
        .status(422)
        .json({ decision: "block", reason: "nsfw", checkId: result.id });
    }

    // Persist the upload here (S3, disk, ...), then confirm.
    return res.json({
      decision: "allow",
      checkId: result.id,
      cached: result.cached,
    });
  } catch (err) {
    if (err instanceof QuotaExceededError) {
      return res.status(402).json({ error: "moderation quota exhausted" });
    }
    if (err instanceof RateLimitError) {
      return res
        .status(429)
        .json({ error: "moderation rate limited", retryAfter: err.retryAfter });
    }
    if (err instanceof SafeReelError) {
      // InvalidRequestError, ServiceError, or TransportError after the SDK's
      // automatic retries are exhausted. Fail closed for UGC.
      console.error(`${err.code} (HTTP ${err.httpStatus}): ${err.message}`);
      return res.status(502).json({ error: "moderation unavailable" });
    }
    throw err;
  }
});

// Multer errors arrive here, not in the route's try/catch.
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  if (err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE") {
    return res.status(413).json({ error: "image exceeds the 10MB limit" });
  }
  console.error(err);
  return res.status(500).json({ error: "internal error" });
});

app.listen(3000);

A few things worth knowing about this code:

On scope: nsfw means pornographic content. Non-explicit nudity, swimwear, and medical or artistic contexts are deliberately out of scope — see the measured behavior in the accuracy report before you tune your product policy around it.

Next.js App Router variant

The same flow as a route handler, taking the raw request body instead of multipart. The client posts the image bytes directly with the image content type:

// app/api/moderate/route.ts
import { NextResponse } from "next/server";
import {
  SafeReel,
  QuotaExceededError,
  RateLimitError,
  SafeReelError,
} from "@safereel/sdk";

export const runtime = "nodejs";

const client = new SafeReel(); // reads SAFEREEL_API_KEY
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX_BYTES = 10 * 1024 * 1024;

export async function POST(req: Request) {
  const contentType = req.headers.get("content-type") ?? "";
  if (!ALLOWED_TYPES.has(contentType)) {
    return NextResponse.json(
      { error: "image/jpeg, image/png or image/webp required" },
      { status: 415 },
    );
  }

  const bytes = await req.arrayBuffer();
  if (bytes.byteLength === 0 || bytes.byteLength > MAX_BYTES) {
    return NextResponse.json(
      { error: "image must be between 1 byte and 10MB" },
      { status: 413 },
    );
  }

  try {
    const result = await client.checkImage(new Uint8Array(bytes), contentType);
    const decision = result.verdict === "nsfw" ? "block" : "allow";
    return NextResponse.json(
      { decision, checkId: result.id },
      { status: decision === "block" ? 422 : 200 },
    );
  } catch (err) {
    if (err instanceof QuotaExceededError) {
      return NextResponse.json({ error: "moderation quota exhausted" }, { status: 402 });
    }
    if (err instanceof RateLimitError) {
      return NextResponse.json(
        { error: "moderation rate limited", retryAfter: err.retryAfter },
        { status: 429 },
      );
    }
    if (err instanceof SafeReelError) {
      console.error(`${err.code} (HTTP ${err.httpStatus}): ${err.message}`);
      return NextResponse.json({ error: "moderation unavailable" }, { status: 502 });
    }
    throw err;
  }
}

If you prefer multipart in Next.js, use await req.formData() and read the file's bytes with await file.arrayBuffer(); the SafeReel call is identical.

Errors and edge cases

Every SDK failure is a subclass of SafeReelError carrying httpStatus, the raw API code string, and retryAfter (seconds, when the server provides one). The cases that matter in an upload flow:

Decide your failure policy up front. For UGC, failing closed (rejecting the upload when moderation is unreachable, as the examples above do) is usually the right default — a fail-open path is an obvious abuse vector.

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.