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.
fetch).export SAFEREEL_API_KEY=sr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
npm install @safereel/sdk express multer
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.
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:
checkImage(bytes, contentType) takes a Uint8Array (a multer Buffer qualifies) and one of image/jpeg, image/png, image/webp. It validates the type and the 10MB ceiling client-side and throws InvalidRequestError before any network call, so an oversized file never becomes a billable request.verdict ("clean" | "nsfw"), confidence, cached, and processing_ms. Treat confidence as informational — verdicts are currently deterministic and boolean, so it is always 1.0 or 0.0.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.
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.
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:
RateLimitError (429). The free tier allows 1 req/s on image checks and the per-second limit applies even to cache hits. The SDK already retries 429s automatically (3 attempts, honoring the server's retry_after hint), so catching this error means the retries were exhausted — back off by err.retryAfter or shed load.QuotaExceededError (402). Your daily/monthly image allowance is spent. Only real inference counts; cached checks are free. Upgrade, or degrade gracefully.InvalidRequestError (400/413/415). Wrong content type, oversized payload, or undecodable bytes. Note that a multipart mimetype is client-supplied and can lie — if the bytes do not decode as a real jpeg/png/webp, the API returns 415 unsupported_media and the SDK surfaces it as this error. Do not retry these.ServiceError (503 inference_unavailable / service_starting). Capacity momentarily full. The SDK retries these with exponential backoff; if you still see the error, wait a few seconds and try again.TransportError. Network failure after retries. httpStatus is null; the underlying error is on cause.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.
Need an API key? Get one free at safereel.ai — 1,000 image checks and 100 video-minutes per month, no card required.