# SFW API: full documentation > Nudity detection API with four content levels (Everyday, Revealing, Implied nudity, Explicit), art and adult-object flags and a blocking policy. GIF and video frames included. Processed in memory in Germany, never stored. API base: https://api.sfwapi.dev/v1 · OpenAPI: https://sfwapi.dev/openapi.json · Index: https://sfwapi.dev/llms.txt · Markdown per page: https://sfwapi.dev/md/ ## For coding agents - Levels: 0 Everyday, 1 Revealing, 2 Implied Nudity, 3 Explicit. Scores are 0-100; above 50 counts. Flags: art, sex_object. - Send `policy.block_level` (default 2) and act on `policy.blocked`; do not re-derive thresholds client-side. - Official SDKs: npm `@sfwapi/sdk`, pip `sfwapi`. Otherwise plain HTTPS with a bearer key. - Read the key from `SFWAPI_API_KEY` on the server. Never ship it to a browser or mobile app. - 4xx: do not retry. 429 and 503: retry after `Retry-After`. Failed requests are not charged. - Never log or store media, URLs or hashes; keep level, scores and `request_id`. - Claude Code plugin: `claude plugin marketplace add superstreber3/sfwapi-agent-tools` then `claude plugin install sfwapi@sfwapi-agent-tools` (source: https://github.com/superstreber3/sfwapi-agent-tools). MCP server: coming soon. Prompt that works in any agent: > Add nudity moderation to image uploads using the SFW API. Read https://sfwapi.dev/llms-full.txt first. Use the official SDK if one exists for this language (npm @sfwapi/sdk, pip sfwapi), otherwise POST to https://api.sfwapi.dev/v1/analyze. Block at level 2 and above by default and make the level configurable. Keep the API key in the environment variable SFWAPI_API_KEY on the server and never send it to the browser. Retry only on 429 and 503, using the Retry-After header. ### Example ```bash curl -X POST https://api.sfwapi.dev/v1/analyze \ -H "Authorization: Bearer $SFWAPI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/photo.gif", "policy": {"block_level": 2}}' ``` ```json { "nudity": { "level": 2, "scores": { "revealing": 96.2, "implied_nudity": 71.5, "explicit": 8.1 } }, "flags": { "art": { "score": 3.4, "detected": false }, "sex_object": { "score": 0.9, "detected": false } }, "policy": { "blocked": true, "reason": "level", "matched_level": 2 }, "media": { "kind": "gif", "frames_analyzed": 5 }, "usage": { "credits": 5, "balance": 9995 } } ``` ## Overview Source: https://sfwapi.dev/docs > What the SFW API returns, how the four nudity levels work, and the mental model in sixty seconds. The SFW API scores an image, GIF or video for nudity and gives you back a **level** from 0 to 3 instead of a single yes/no: | Level | Name | Means | |---|---|---| | 0 | Everyday | Regular, fully clothed content. Always allowed. | | 1 | Revealing | Swimwear, lingerie and outfits that show more skin. | | 2 | Implied Nudity | Unclothed, with intimate areas covered or out of view. | | 3 | Explicit | Visible intimate areas. | Next to the level you get three **scores** (0–100, one per level threshold), two **flags** (`art` for drawings, anime, statues and stylised 3D; `sex_object` for adult toys), and, if you send a **policy**, a `blocked` verdict computed with the same engine the SfwBot Discord bot uses for thousands of servers. ```json { "nudity": { "level": 2, "scores": { "revealing": 96.2, "implied_nudity": 71.5, "explicit": 8.1 } }, "flags": { "art": { "score": 3.4, "detected": false }, "sex_object": { "score": 0.9, "detected": false } }, "policy": { "blocked": true, "reason": "level", "matched_level": 2 } } ``` ## The one rule to remember Every score is calibrated so that **50 is the decision threshold**. A level counts when its score is above 50. The level of a media item is the highest level whose score is above 50, taken over all analysed frames. ## What never happens Media is processed in memory and discarded. We do not store the bytes, the URL, a hash or the file name, and none of them appear in logs. Failed requests are never charged. See [Data handling](https://sfwapi.dev/docs/data-handling). ## Where to go next - [Quickstart](https://sfwapi.dev/docs/quickstart): first call in two minutes. - [Understanding levels](https://sfwapi.dev/docs/levels) and [Policies](https://sfwapi.dev/docs/policies). - [Analyze endpoint](https://sfwapi.dev/docs/analyze), [Errors](https://sfwapi.dev/docs/errors), [Rate limits & credits](https://sfwapi.dev/docs/rate-limits-and-credits). ## Quickstart Source: https://sfwapi.dev/docs/quickstart > Get a free API key and analyse your first image with curl, TypeScript or Python in two minutes. 1. Create a free key in the [console](https://sfwapi.dev/console). The free tier gives you 500 credits a month. 2. Send an image URL: ```bash curl -X POST https://api.sfwapi.dev/v1/analyze \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/photo.jpg", "policy": {"block_level": 2}}' ``` Or upload a file: ```bash curl -X POST https://api.sfwapi.dev/v1/analyze \ -H "Authorization: Bearer sk_live_…" \ -F "file=@photo.jpg" \ -F 'policy={"block_level":2,"block_sex_objects":true}' ``` ## TypeScript ```ts import { SfwApi } from '@sfwapi/sdk'; const api = new SfwApi({ apiKey: process.env.SFWAPI_KEY! }); const r = await api.analyzeUrl('https://example.com/photo.jpg', { policy: { block_level: 2, block_sex_objects: true, art: 'same_as_photos' } }); if (r.policy?.blocked) console.log(`blocked at level ${r.policy.matched_level}`); console.log(r.nudity.level, r.nudity.scores, r.usage.balance); ``` ## Python ```python from sfwapi import SfwApi api = SfwApi(api_key=os.environ["SFWAPI_KEY"]) r = api.analyze_url("https://example.com/photo.jpg", policy={"block_level": 2, "block_sex_objects": True}) print(r["nudity"]["level"], r["policy"]["blocked"], r["usage"]["balance"]) ``` ## Reading the answer - `nudity.level` is the headline: 0 Everyday, 1 Revealing, 2 Implied nudity, 3 Explicit. - `nudity.scores` are 0–100; above 50 counts. Scores near 50 mean the model is unsure. - `policy.blocked` is only present when you sent a `policy`. - `usage.credits` is what this call cost and `usage.balance` what is left. Keep the key on your server. Browsers and mobile apps should call your backend, not the API directly. ## Use it from Claude Code, Codex or Cursor Source: https://sfwapi.dev/docs/ai-agents > Install the SFW API skill in your coding agent or paste one prompt, and let the agent write the integration against our Markdown docs. Most integrations are written by a coding agent these days. The SFW API is set up for that: every docs page exists as Markdown, the whole documentation fits in one file, and there is a Claude Code plugin that tells the agent when and how to use the API. ## Claude Code Install the plugin once; the skill is picked up whenever you ask for nudity detection, an NSFW filter or upload moderation. ```bash claude plugin marketplace add superstreber3/sfwapi-agent-tools claude plugin install sfwapi@sfwapi-agent-tools ``` Inside a session the same two steps are `/plugin marketplace add superstreber3/sfwapi-agent-tools` and `/plugin install sfwapi@sfwapi-agent-tools`. Third-party marketplaces do not auto-update by default; turn it on under `/plugin` if you want new versions. ## Codex Codex reads `AGENTS.md` from your repository root. Add this block and ask it to add moderation: ```markdown ## Image moderation Use the SFW API for nudity detection: POST https://api.sfwapi.dev/v1/analyze with a URL, base64 or multipart file. Before writing code, read https://sfwapi.dev/llms-full.txt (full docs) and https://sfwapi.dev/openapi.json. Levels: 0 Everyday, 1 Revealing, 2 Implied nudity, 3 Explicit. Scores are 0-100; above 50 counts. Send policy.block_level (default 2) and act on policy.blocked instead of re-deriving thresholds. Prefer the official SDK: npm @sfwapi/sdk or pip sfwapi. Read the key from SFWAPI_API_KEY, server-side only. 4xx means do not retry; 429 and 503 mean retry after Retry-After. Failed requests are not charged. Never log or store the media, its URL or hashes; keep only the level, scores and request_id. ``` ## Cursor Save the same body as a project rule in `.cursor/rules/sfwapi.mdc`: ```markdown --- description: SFW API nudity moderation for image uploads alwaysApply: false --- Use the SFW API for nudity detection: POST https://api.sfwapi.dev/v1/analyze with a URL, base64 or multipart file. Before writing code, read https://sfwapi.dev/llms-full.txt (full docs) and https://sfwapi.dev/openapi.json. Levels: 0 Everyday, 1 Revealing, 2 Implied nudity, 3 Explicit. Scores are 0-100; above 50 counts. Send policy.block_level (default 2) and act on policy.blocked instead of re-deriving thresholds. Prefer the official SDK: npm @sfwapi/sdk or pip sfwapi. Read the key from SFWAPI_API_KEY, server-side only. 4xx means do not retry; 429 and 503 mean retry after Retry-After. Failed requests are not charged. Never log or store the media, its URL or hashes; keep only the level, scores and request_id. ``` Cursor also reads `AGENTS.md` and `.claude/skills/`, so the Codex block works there too. ## Any other agent No install at all. Paste this into Copilot, Windsurf, Aider or a chat window: ```text Add nudity moderation to image uploads using the SFW API. Read https://sfwapi.dev/llms-full.txt first. Use the official SDK if one exists for this language (npm @sfwapi/sdk, pip sfwapi), otherwise POST to https://api.sfwapi.dev/v1/analyze. Block at level 2 and above by default and make the level configurable. Keep the API key in the environment variable SFWAPI_API_KEY on the server and never send it to the browser. Retry only on 429 and 503, using the Retry-After header. ``` ## What the agent reads | Resource | URL | Use | | --- | --- | --- | | Index | `/llms.txt` | Short map of the site for agents | | Full docs | `/llms-full.txt` | Every docs page in one Markdown file | | Any page as Markdown | `/md/`, for example `/md/quickstart` | Same text as the rendered page | | OpenAPI 3.1 | `/openapi.json` | Request and response schemas | | Plugin source | [github.com/superstreber3/sfwapi-agent-tools](https://github.com/superstreber3/sfwapi-agent-tools) | Skill, AGENTS.md and Cursor rule | An MCP server that exposes `analyze_url` and `analyze_file` as tools is coming; the plugin will add it when it ships. The key stays on your server. The agent should read it from `SFWAPI_API_KEY`; browsers and mobile apps call your backend, never the API directly. ## Authentication Source: https://sfwapi.dev/docs/authentication > API keys, where to keep them, rotation and revocation. Every request carries a key in the `Authorization` header: ``` Authorization: Bearer sk_live_a1B2c3D4… ``` Keys are created in the [console](https://sfwapi.dev/console/keys). You see the full key once; we store only a SHA-256 hash and the first eight characters for display. Up to ten keys per account, each with a name, so one key per service or environment is the normal setup. **Rotate** a key from the console: a new key is issued immediately and the old one keeps working for 24 hours. **Revoke** takes effect at once. Never ship a key to a browser or an app bundle. If a key leaks, revoke it; the console shows when each key was last used and from which network prefix (never a full address). Missing or wrong keys answer `401 invalid_api_key`, revoked or rotated-out keys `403 key_revoked`. ## Analyze endpoint Source: https://sfwapi.dev/docs/analyze > POST /v1/analyze — inputs (URL, base64, multipart), options (max_frames, include_frames, policy) and the full response. `POST https://api.sfwapi.dev/v1/analyze` ## Input Exactly one media source per request: | How | Body | Notes | |---|---|---| | URL | `{"url": "https://…"}` | Public http(s) URL. Our servers download it; redirects are followed up to 3 hops; private and local addresses are refused. | | Upload | multipart field `file` | Preferred for files you already hold. The file name you send is discarded. | | Base64 | `{"base64": "…"}` | Convenient from a canvas; costs about a third more bandwidth than an upload. A `data:` prefix is stripped. | Options (JSON fields, or form fields on multipart): | Field | Type | Default | Meaning | |---|---|---|---| | `max_frames` | 1–32 | 5 for GIF, 10 for video | Frames sampled evenly over the clip (first and last always included). One credit per analysed frame. | | `include_frames` | boolean | `false` | Return `frames[]` with per-frame scores and levels. | | `policy` | object | none | See [Policies](https://sfwapi.dev/docs/policies). On multipart, send it as a JSON string. | Accepted media: JPEG, PNG, WebP, GIF, BMP, TIFF, AVIF, HEIC, MP4, MOV, WebM, MKV, AVI. Size limits depend on your tier (free: 8 MB, no video; paid: 32 MB uploads, 32 MB images, 64 MB GIFs and videos by URL). Sync requests time out after 60 seconds. ## Response ```json { "id": "an_01JD4Q0K7X3Y9Z8W2V1U0T9S8R", "media": { "kind": "gif", "width": 480, "height": 270, "frames_analyzed": 5, "duration_ms": 2400 }, "nudity": { "level": 2, "scores": { "revealing": 96.2, "implied_nudity": 71.5, "explicit": 8.1 } }, "flags": { "art": { "score": 3.4, "detected": false }, "sex_object": { "score": 0.9, "detected": false } }, "policy": { "blocked": true, "reason": "level", "matched_level": 2 }, "frames": [ { "index": 0, "timestamp_ms": 0, "level": 0, "scores": { "…": 0 }, "flags": { "…": 0 } } ], "usage": { "credits": 5, "balance": 9995 }, "timing_ms": { "upstream": 1840, "total": 1862 } } ``` - `nudity.level`: highest level whose score is above 50 in any frame. - `nudity.scores`: maximum over frames per level threshold. - `flags.*.detected` is `score > 50`. - `policy` appears only when a policy was sent; `reason` is `level`, `sex_object` or `art_level`. - `frames` appears only with `include_frames: true`. Headers: `X-Request-Id` (quote it to support), `X-Credits-Remaining`, `RateLimit`, `RateLimit-Policy`, `Retry-After` on 429/503. The machine-readable contract is at [`/openapi.json`](https://api.sfwapi.dev/openapi.json); an interactive reference lives at [api.sfwapi.dev/reference](https://api.sfwapi.dev/reference). ## Understanding levels Source: https://sfwapi.dev/docs/levels > What each of the four nudity levels means, how scores become a level, and what never raises the level. | Level | Name | Definition | Score field | |---|---|---|---| | 0 | Everyday | Regular, fully clothed content. Always allowed. | implied when every score is ≤ 50 | | 1 | Revealing | Swimwear, lingerie and outfits that show more skin. | `scores.revealing` | | 2 | Implied Nudity | Unclothed, with intimate areas covered or out of view. | `scores.implied_nudity` | | 3 | Explicit | Visible intimate areas. | `scores.explicit` | The levels are **ordinal**: `scores.revealing` is the probability of level ≥ 1, `scores.implied_nudity` of level ≥ 2, `scores.explicit` of level 3. They are clamped so the higher threshold never scores above the lower one. ## Calibration Each head is calibrated so its tuned threshold sits exactly at 50. That means a `revealing` of 51 and an `explicit` of 51 are equally "just over the line", and you can compare levels on one scale. Scores between roughly 35 and 65 are the model's grey zone; if you route such items to human review, that is the band to use. ## Flags - `art`: drawings, anime, paintings, statues, stylised 3D. The level is judged the same way as for photos; the flag lets your policy treat art differently. - `sex_object`: adult toys and genital-shaped objects, independent of nudity. ## What never raises the level By labelling rule, these do **not** move an image up a level on their own: pose, camera angle, framing or close-ups, facial expression, innuendo, tight clothes, cleavage however deep, a flat chest. When the labeller was torn between two levels, the lower one won, so the model is conservative by design. ## GIFs and videos Frames are sampled evenly across the clip. The media level is the maximum over frames; `include_frames: true` shows you which frame drove it. ## Policies Source: https://sfwapi.dev/docs/policies > Send your moderation settings with the request and get a blocked verdict, computed exactly like the SfwBot Discord bot does. A policy turns scores into a decision on our side, so your code does not have to re-implement the rules: ```json { "policy": { "block_level": 2, "block_sex_objects": true, "art": "block_from_3" } } ``` | Field | Values | Meaning | |---|---|---| | `block_level` | 1, 2, 3 | Block media at this level or above. | | `block_sex_objects` | boolean | Also block when an adult object is detected. Default `false`. | | `art` | `same_as_photos`, `allow`, `block_from_1`, `block_from_2`, `block_from_3` | How frames the model flags as art are judged. Default `same_as_photos`. | The response then carries: ```json { "policy": { "blocked": true, "reason": "level", "matched_level": 2 } } ``` `reason` is `level` (a level at or above `block_level`), `sex_object`, or `art_level` (a level reached under the art rule). `matched_level` is the highest blocked level seen in any frame. ## How it is evaluated For every analysed frame: if the frame is art (`art` score above 50) and the art rule is not `same_as_photos`, the art rule decides the level to block from (`allow` skips the frame). The frame's score is the maximum of the level scores at or above that level, plus the adult-object score when `block_sex_objects` is on. The media is blocked when any frame's score is above 50. This is a line-for-line port of the evaluator the SfwBot Discord bot applies to each server's settings, so a policy that works for a community in Discord works the same here. ## Typical policies | Community | Policy | |---|---| | General audience, art welcome | `{ "block_level": 1, "block_sex_objects": true, "art": "block_from_2" }` | | Adults, no explicit content | `{ "block_level": 3, "block_sex_objects": false }` | | Swimwear fine, nudity not | `{ "block_level": 2, "block_sex_objects": true }` | | Dating profile photos | `{ "block_level": 2, "block_sex_objects": true, "art": "block_from_1" }` | You can also evaluate policies client-side from the returned scores; the TypeScript SDK exports the same `evaluatePolicy` function. ## GIF and video Source: https://sfwapi.dev/docs/gif-and-video > How frames are sampled, what they cost, and how to read per-frame results. GIFs and videos are analysed frame by frame. Frames are spread evenly over the whole clip, first and last always included, up to `max_frames` (default 5 for GIFs, 10 for videos, maximum 32). Each analysed frame costs one credit. ```json { "url": "https://example.com/clip.mp4", "max_frames": 8, "include_frames": true } ``` The media level is the maximum over frames. With `include_frames: true` you get: ```json "frames": [ { "index": 0, "timestamp_ms": 0, "level": 0, "scores": { "revealing": 4.1, "implied_nudity": 1.2, "explicit": 0.3 }, "flags": { "art": { "score": 2.0, "detected": false }, "sex_object": { "score": 0.1, "detected": false } } }, { "index": 47, "timestamp_ms": 2350, "level": 2, "scores": { "revealing": 96.2, "implied_nudity": 71.5, "explicit": 8.1 }, "flags": { "…": 0 } } ] ``` Guidance: 5 frames catch a short meme GIF; 10 cover a typical 10–30 s clip; use 16–32 for longer uploads where a brief scene matters. Videos must finish within the 60 s request budget; very long files should be cut or sampled on your side. Video requires a paid account; free accounts can analyse GIFs up to 8 MB. ## Errors and retries Source: https://sfwapi.dev/docs/errors > The error envelope, every code, what is charged, and how to retry. Every error has one shape: ```json { "error": { "type": "billing_error", "code": "insufficient_credits", "message": "…", "doc_url": "https://sfwapi.dev/docs/errors#insufficient_credits", "request_id": "01JD…" } } ``` Failed requests are **never charged**. | HTTP | `code` | Retry? | |---|---|---| | 400 | `invalid_request`, `url_not_allowed` | No, fix the request | | 401 | `invalid_api_key` | No | | 402 | `insufficient_credits` | After a top-up | | 403 | `key_revoked`, `account_suspended`, `email_not_verified` | No | | 413 | `media_too_large` (details.limit_bytes) | No | | 415 | `unsupported_media` | No | | 422 | `download_failed`, `decode_failed` | Fix the file or URL | | 429 | `rate_limited`, `concurrency_exceeded`, `quota_exceeded` | Yes, honour `Retry-After` | | 502 | `upstream_internal` | Yes, with backoff | | 503 | `unavailable`, `overloaded`, `bandwidth_limited` | Yes, honour `Retry-After` | | 504 | `timeout` | Once, with a smaller file or fewer frames | Retry 429, 502, 503 and 504 with exponential backoff and jitter (the SDKs do this twice by default, except for uploads). Everything else is a request problem. ## Codes in detail - `url_not_allowed`: only public http(s) URLs; addresses that resolve to private, loopback or link-local ranges are refused. - `download_failed`: the host did not answer, answered 4xx/5xx, or redirected too often. - `decode_failed`: the bytes are not a decodable image, GIF or video. - `bandwidth_limited`: large media and video are paused while the service is near its monthly traffic allowance; images under 10 MB keep working. - `quota_exceeded`: the anonymous playground allowance for today is used up. ## Rate limits and credits Source: https://sfwapi.dev/docs/rate-limits-and-credits > How requests are limited per key and account, how credits are counted, and how auto-recharge works. ## Credits | What | Credits | |---|---| | Image | 1 | | GIF or video | 1 per analysed frame (`max_frames`, default 5 / 10) | | Media above 10 MB | +1 per started 10 MB | | Any error | 0 | Free accounts get 500 credits on the first day of each month (UTC). Prepaid packs never expire and are used after the free credits. `GET /v1/usage` and `GET /v1/me` show the balance; every analyze response carries `usage.balance` and the `X-Credits-Remaining` header. ## Limits | | Free | Paid | |---|---|---| | Requests per second | 1 (burst 3) | 3 (burst 15), higher on request | | In flight | 2 | 8 | | Media size | 8 MB, no video | 32 MB uploads; 64 MB video by URL | | Bandwidth per day | 200 MB | 20 GB | Rate limits answer `429` with `Retry-After` and the `RateLimit` / `RateLimit-Policy` headers. When all model replicas are busy you get `503 overloaded` and paying traffic is served first; wait the suggested seconds and retry. ## Auto-recharge In the console you can save a card and set a threshold: when the balance drops below it, the chosen pack is bought automatically (at most three times a day) through a Stripe invoice, so tax is applied correctly and you receive an invoice. After three failed charges auto-recharge switches itself off and you get an email. ## Data handling and privacy Source: https://sfwapi.dev/docs/data-handling > What is processed, what is stored and for how long, where it runs, and who the sub-processors are. | Data | Stored? | Retention | |---|---|---| | Media bytes, URLs, base64, file names, perceptual hashes | **No** — in memory only, discarded after the response | 0 | | Per-request metadata without content: request id, key id, status, credits, media kind, frame count, level result, latency | Yes | 7 days, then only aggregates | | Hourly aggregates (requests, credits, bytes, levels, latency) | Yes | account lifetime | | Key metadata: name, prefix, hash, last used at, last network prefix (/24 or /48) and country | Yes | key lifetime; network prefix 30 days | | Account: email, name, company, VAT ID, country | Yes | account lifetime + statutory for invoices | | Billing | at Stripe | statutory | Inference runs on our own servers in Germany. For URL inputs, our server downloads the media (through our IP, with private-network targets refused). Transport to the API is TLS through Cloudflare; the website on Vercel never handles media or account data (the console talks to the API directly from your browser). Roles under the GDPR: for media and results we are your **processor** (the API call is your instruction); for account and billing data we are the controller. The [DPA](https://sfwapi.dev/legal/dpa) is part of the terms; the [sub-processor list](https://sfwapi.dev/legal/subprocessors) is kept current. Failed requests are never charged and leave no content behind either. ## Python SDK Source: https://sfwapi.dev/docs/sdks/python > The sfwapi package, sync and async, on httpx. ```bash pip install sfwapi ``` ```python from sfwapi import SfwApi, SfwApiError api = SfwApi(api_key=os.environ["SFWAPI_KEY"]) try: r = api.analyze_url("https://example.com/photo.jpg", policy={"block_level": 2}) print(r["nudity"]["level"], r["policy"]["blocked"]) except SfwApiError as e: print(e.status, e.code, e.message, e.request_id) with open("clip.gif", "rb") as f: g = api.analyze_bytes(f.read(), max_frames=5, include_frames=True) ``` `AsyncSfwApi` has the same methods with `await`. Methods: `analyze_url`, `analyze_bytes`, `analyze_file`, `me`, `usage`. 429/503/504 are retried twice honouring `Retry-After` (not uploads). ## TypeScript SDK Source: https://sfwapi.dev/docs/sdks/typescript > @sfwapi/sdk for Node 18+, Bun, Deno and edge runtimes. ```bash npm install @sfwapi/sdk ``` ```ts import { SfwApi, SfwApiError, evaluatePolicy } from '@sfwapi/sdk'; const api = new SfwApi({ apiKey: process.env.SFWAPI_KEY! }); try { const r = await api.analyzeUrl('https://example.com/photo.jpg', { policy: { block_level: 2, block_sex_objects: true, art: 'same_as_photos' } }); console.log(r.nudity.level, r.policy?.blocked); } catch (e) { if (e instanceof SfwApiError) console.error(e.status, e.code, e.message, e.requestId); } // uploads const bytes = await fs.promises.readFile('clip.gif'); const g = await api.analyzeFile(bytes, { maxFrames: 5, includeFrames: true }); ``` Methods: `analyzeUrl`, `analyzeFile`, `analyzeBase64`, `me`, `usage`. Options: `maxFrames`, `includeFrames`, `policy`, `signal`. 429/503/504 are retried twice honouring `Retry-After` (not uploads); set `maxRetries: 0` to disable. The exported `evaluatePolicy(frames, policy)` is the same evaluator the API uses, for client-side what-if logic. ## Pricing 500 free credits every month, no card. Prepaid packs that never expire: $10 for 10,000 credits ($1.00 per 1,000); $40 for 50,000 credits ($0.80 per 1,000); $150 for 250,000 credits ($0.60 per 1,000); $500 for 1,000,000 credits ($0.50 per 1,000). One credit per image or analysed frame. Full page: https://sfwapi.dev/pricing ## Accuracy Model v19 (trained 2026-11-03), frozen test split of 5,971 images: 9 of 10 explicit images caught (90.8 %); Fewer than 2 in 100 everyday photos flagged (1.8 %); 19 of 20 images land on the exact level (94.9 %). Every metric with intervals: https://sfwapi.dev/accuracy