Moderation API
Check whether an image or video contains NSFW content, in a single synchronous call.
Moderation answers one question about a piece of content: is it NSFW, yes or no?
It accepts images, video and plain text, and unlike generation endpoints it is synchronous — the verdict comes back in the same response, typically in 400–500ms for an image or a block of text. There is no job to submit, no id to poll, and no webhook to wire up.
Text is read natively in 12 languages (English, Chinese, Japanese, Korean, French, Spanish, German, Italian, Portuguese, Dutch, Arabic, Russian), so there is no translation step.
$0.001 per call — flat, whether you send an image, ten minutes of video, or 20,000 characters of text. The first 10 calls on your account are free.
POST https://aircube.ai/api/v3/moderationWant to try it before writing any code? The same classifier runs behind the NSFW Detector tool, which accepts direct file uploads and shows the per-frame scores for videos.
Request
Send exactly one of image_url, video_url or text.
{
"image_url": "https://example.com/photo.jpg"
}| Field | Type | Required | Description |
|---|---|---|---|
image_url | string | One of the three | Public http(s) URL of an image |
video_url | string | One of the three | Public http(s) URL of a video |
text | string | One of the three | Plain text to classify. Up to 20,000 characters |
policy | string | No | What to check for: nsfw (default), violence, hate |
threshold | number | No | Score at or above which content counts as a violation. 0–1, default 0.5 |
max_frames | number | No | Video only. How many frames to sample. 1–16, default 8 |
Response (200 OK)
Image
{
"success": true,
"data": {
"nsfw": false,
"score": 0.0141,
"threshold": 0.5,
"media_type": "image",
"duration_ms": 466
}
}nsfw is the yes/no answer — it is simply score >= threshold. If that is all you need, ignore the rest.
| Field | Type | Description |
|---|---|---|
nsfw | boolean | The verdict |
score | number | Confidence that the content is NSFW, 0–1 |
threshold | number | The threshold the verdict was made against |
media_type | string | image, video or text |
policy | string | Which policy produced the verdict |
duration_ms | number | Server-side processing time. For video this covers download, frame sampling and every classifier call — it excludes your own round trip to the API |
Text
{
"success": true,
"data": {
"nsfw": true,
"score": 0.9797,
"threshold": 0.5,
"media_type": "text",
"duration_ms": 512
}
}Same shape as an image — no frames. Send it like this:
curl -X POST https://aircube.ai/api/v3/moderation \
-H "Authorization: Bearer $AIRCUBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "the user comment you want to screen"}'Video
Videos are sampled into evenly spaced frames, and each frame is checked in turn. score is the highest score of any frame checked.
{
"success": true,
"data": {
"nsfw": true,
"score": 0.6514,
"threshold": 0.5,
"media_type": "video",
"duration_ms": 2570,
"frames_checked": 3,
"frames": [
{ "timestamp": 0.32, "score": 0.0183 },
{ "timestamp": 0.97, "score": 0.0225 },
{ "timestamp": 1.61, "score": 0.6514 }
]
}
}| Field | Type | Description |
|---|---|---|
frames_checked | number | How many frames were actually classified |
frames | array | Each frame's timestamp (seconds into the video) and score |
Checking stops at the first frame that crosses the threshold, since one flagged frame is enough to flag the video. That is why frames_checked is often smaller than max_frames. To score every frame regardless — useful when profiling a video or tuning your threshold — set threshold to 1.
Policies
By default the check asks one question: is this sexually explicit? Pass policy to ask a different one instead. Everything else — pricing, video frame sampling, early exit, the response shape — is identical.
{ "text": "How can I hurt someone without being caught?", "policy": "violence" }| Policy | Checks for | Validated on |
|---|---|---|
nsfw (default) | Nudity, sexual activity, sexually explicit material | Text, images and video |
violence | Content promoting or describing physical violence against people | Text. Images are not yet verified — see below |
hate | Hate speech or dehumanizing language targeting a protected group | Text. Images are not yet verified — see below |
One call checks one policy. To apply several, send several requests — each is billed separately.
A caveat worth reading before you rely on violence or hate for images
These two have been measured on text and separate cleanly there: threats and dehumanizing statements score 0.85+, while the traps that usually cause false positives stay low — staged fight choreography 0.09, a surgical incision 0.11, disagreeing with a country's foreign policy 0.02.
On images, only the false-positive direction has been checked (an explicit photo scores 0.27 for violence, 0.05 for hate — it does not misfire). Whether they reliably catch violent or hateful imagery has not been measured.
That gap is worth taking seriously: this classifier degrades to noise outside the domain it was trained on. Asked whether an image was AI-generated, and separately whether it carried a watermark, it returned the identical score for both — two unrelated questions, one meaningless answer. Strong text results do not transfer to images on their own.
So: nsfw is production-ready for all three media types. For violence and hate, use them on text with confidence, and validate against your own images before trusting them there.
Choosing a threshold
The default of 0.5 suits a general-audience platform. These are approximate nsfw scores for reference:
| Content | Typical score |
|---|---|
| Landscapes, pets, ordinary photos of people | 0.01 – 0.03 |
| Swimwear, beachwear | ~0.45 |
| Lingerie, strongly suggestive | 0.70 – 0.80 |
| Nudity, sexual activity | 0.90+ |
Raise the threshold to be more permissive, lower it to be stricter. Ordinary swimwear, underwear, fitness and non-explicit artistic content are deliberately not treated as NSFW at the default threshold.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Zero or more than one of image_url / video_url / text sent; URL is not http(s); text over 20,000 characters; unknown policy; threshold or max_frames out of range; media could not be fetched or read |
| 401 | UNAUTHORIZED | Missing or invalid API key |
| 402 | PAYMENT_REQUIRED | Insufficient balance to settle the completed batch |
| 429 | RATE_LIMITED | Rate limit exceeded for your key |
| 500 | INTERNAL_ERROR | Classification failed |
Billing
$0.001 is a tenth of a cent, below the resolution of the credits ledger, so calls are counted and charged in batches rather than one at a time:
- The first 10 calls on your account are free.
- After that, every 10 calls costs 1 cent — exactly $0.001 each.
- Failed calls are not charged and do not count toward a batch.
- A
402 PAYMENT_REQUIREDmeans the call that completed a batch could not be paid for. Top up and retry.
Limits
- URLs must be publicly reachable over http(s). Data URIs and file uploads are not accepted.
- Videos must be 200 MB or smaller.
- Text must be 20,000 characters or fewer.
- Very long videos are still sampled into at most
max_framesframes, so cost and latency stay bounded.
Complete examples
cURL
# Image
curl -X POST https://aircube.ai/api/v3/moderation \
-H "Authorization: Bearer $AIRCUBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"image_url": "https://example.com/photo.jpg"}'
# Video, stricter threshold
curl -X POST https://aircube.ai/api/v3/moderation \
-H "Authorization: Bearer $AIRCUBE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/clip.mp4",
"threshold": 0.4,
"max_frames": 12
}'Python
import os
import requests
API_KEY = os.environ["AIRCUBE_API_KEY"]
def is_nsfw(url, is_video=False, threshold=0.5):
field = "video_url" if is_video else "image_url"
res = requests.post(
"https://aircube.ai/api/v3/moderation",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={field: url, "threshold": threshold},
timeout=300,
)
res.raise_for_status()
return res.json()["data"]
result = is_nsfw("https://example.com/photo.jpg")
if result["nsfw"]:
print(f"Blocked (score {result['score']})")
else:
print(f"Allowed (score {result['score']})")JavaScript
const API_KEY = process.env.AIRCUBE_API_KEY;
async function isNsfw(url, { isVideo = false, threshold = 0.5 } = {}) {
const res = await fetch("https://aircube.ai/api/v3/moderation", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
[isVideo ? "video_url" : "image_url"]: url,
threshold,
}),
});
const body = await res.json();
if (!body.success) {
throw new Error(body.error.message);
}
return body.data;
}
const result = await isNsfw("https://example.com/clip.mp4", { isVideo: true });
console.log(result.nsfw ? "Blocked" : "Allowed", result.score);