> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.beatapi.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.beatapi.io/_mcp/server.

# Moderation API

> Check one text against seven content categories and get back a score per category plus an allow, review or block verdict, in the request and response shape of OpenAI's Moderation API.

# Moderation API

Send one piece of text and get back a judgement you can act on: a score from `0` to `1` for each of seven categories, whether each one crossed its line, and a single verdict — `allow`, `review` or `block`. It is built for the checks around user-written and model-written text: a comment before it is published, a prompt before it reaches a model, a reply before a user sees it.

`POST https://api.beatapi.io/v1/moderations`

The request and response follow OpenAI's Moderation API, so the official OpenAI SDKs work with only the base URL and the API key changed. This was verified on 2026-10-12 with the official Python SDK (`openai` 2.48.0).

| Model               | What it is                                                                                                                         |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `beat-moderation-1` | BeatAPI's moderation model. \$0.042 per 1M input tokens. `model` is optional in the request; every call is answered by this model. |

> **Note**
>
> A failed call judged nothing. Never treat an error — a timeout, a `4xx`, a `503` — as "allowed". Hold the content, or retry.

## Quick start

```bash
curl https://api.beatapi.io/v1/moderations \
  -H "Authorization: Bearer $BEATAPI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"input": "Forged ID cards and bank cards for sale, shipped within 24 hours. Message me for the price list."}'
```

The response below is a real one, returned for an advertisement of this kind that offered forged ID cards and bank cards.

```json
{
  "id": "modr-task_UnzjwhQHAITmqaLLJUi3ML5gQYnRfDln",
  "model": "beat-moderation-1",
  "results": [
    {
      "flagged": true,
      "categories": {
        "sexual": false,
        "sexual/minors": false,
        "violence": false,
        "hate": false,
        "harassment": false,
        "self-harm": false,
        "illicit": true,
        "harassment/threatening": false,
        "hate/threatening": false,
        "illicit/violent": false,
        "self-harm/intent": false,
        "self-harm/instructions": false,
        "violence/graphic": false
      },
      "category_scores": {
        "sexual": 0,
        "sexual/minors": 0.01,
        "violence": 0.02,
        "hate": 0.01,
        "harassment": 0.02,
        "self-harm": 0.01,
        "illicit": 0.97,
        "harassment/threatening": 0,
        "hate/threatening": 0,
        "illicit/violent": 0,
        "self-harm/intent": 0,
        "self-harm/instructions": 0,
        "violence/graphic": 0
      },
      "category_applied_input_types": {
        "sexual": ["text"],
        "sexual/minors": ["text"],
        "violence": ["text"],
        "hate": ["text"],
        "harassment": ["text"],
        "self-harm": ["text"],
        "illicit": ["text"],
        "harassment/threatening": [],
        "hate/threatening": [],
        "illicit/violent": [],
        "self-harm/intent": [],
        "self-harm/instructions": [],
        "violence/graphic": []
      },
      "verdict": "block",
      "sexual_level": "none"
    }
  ],
  "policy_version": "2026-10-12",
  "usage": { "input_tokens": 1206 }
}
```

`illicit` scored `0.97`, above its line of `0.6`, so `categories.illicit` is `true`, `flagged` is `true` and the `verdict` is `block`.

### OpenAI SDK (Python)

Point the client at BeatAPI and call `moderations.create` as usual. The fields BeatAPI adds are readable on the typed result.

```python
import os

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["BEATAPI_API_KEY"],
    base_url="https://api.beatapi.io/v1",
)

resp = client.moderations.create(
    input="Forged ID cards and bank cards for sale, shipped within 24 hours. Message me for the price list.",
)
result = resp.results[0]

# The values in the comments are the ones from the response above.
print(result.flagged)                  # True
print(result.verdict)                  # "block" (added by BeatAPI)
print(result.categories.illicit)       # True
print(result.category_scores.illicit)  # 0.97
print(result.sexual_level)             # "none" (added by BeatAPI)
print(resp.policy_version)             # "2026-10-12" (added by BeatAPI)
```

### JavaScript

```javascript
const text = "Forged ID cards and bank cards for sale, shipped within 24 hours. Message me for the price list.";

const response = await fetch("https://api.beatapi.io/v1/moderations", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BEATAPI_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ input: text }),
});

if (!response.ok) {
  // A failed call judged nothing: do not let the text through.
  // On HTTP 429, wait the Retry-After seconds before retrying.
  throw new Error(`Moderation HTTP ${response.status}; Retry-After: ${response.headers.get("Retry-After")}`);
}
const moderation = await response.json();
const result = moderation.results?.[0];
if (!result || !["allow", "review", "block"].includes(result.verdict)) {
  throw new Error("Invalid moderation response; hold the text for review");
}

if (result.verdict === "block") {
  console.log("rejected", result.category_scores);
} else if (result.verdict === "review") {
  console.log("queued for a person", result.sexual_level, result.category_scores);
} else {
  console.log("allowed under policy", moderation.policy_version);
}
```

## Request

| Field   | Type            | Required | Description                                                                                                                                                                                                                                                                |
| ------- | --------------- | -------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `input` | string \| array |      Yes | The one text to check: a string, or an array holding exactly one item — a string or `{"type": "text", "text": "..."}`. At most 8,000 characters.                                                                                                                           |
| `model` | string          |       No | `beat-moderation-1`, or one of OpenAI's moderation model names (`omni-moderation-latest`, `omni-moderation-2024-09-26`, `text-moderation-latest`, `text-moderation-stable`), which are accepted and answered by `beat-moderation-1`. Any other name is refused with a 400. |

## Response

| Field                                    | Description                                                                                                          |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `id`                                     | Identifier of this call. Quote it in support tickets.                                                                |
| `model`                                  | Always `beat-moderation-1`, whichever accepted name the request used.                                                |
| `results`                                | An array with one entry, because one text is judged per call.                                                        |
| `results[].flagged`                      | `true` when any category's score reached its line.                                                                   |
| `results[].categories`                   | For each category, whether its score reached its line.                                                               |
| `results[].category_scores`              | For each category, a score from `0` to `1`.                                                                          |
| `results[].category_applied_input_types` | `["text"]` for the seven judged categories and `[]` for the six sub-categories.                                      |
| `results[].verdict`                      | `allow`, `review` or `block`. Added by BeatAPI.                                                                      |
| `results[].sexual_level`                 | `none`, `contextual`, `suggestive` or `explicit`. Added by BeatAPI.                                                  |
| `policy_version`                         | The policy version that judged the text. Scores from different policy versions are not comparable. Added by BeatAPI. |
| `usage.input_tokens`                     | The input tokens this call counted. Added by BeatAPI.                                                                |

The response is synchronous: a successful HTTP response carries the judgement. There is nothing to poll.

## Categories

Seven categories are judged, named as OpenAI names them, each with a score from `0` to `1`.

| Category        | What it flags                                                                                                                                                    | What it deliberately does not flag                                                                                                          |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `sexual`        | Explicit sexual content, requests for it, erotic role-play, and offers or ads for sexual services or pornography.                                                | Medical, sex-education, news or other non-arousing mentions. Merely suggestive content is not flagged either; it gets the `review` verdict. |
| `sexual/minors` | Any sexualisation of a person under 18, however fictional or coded.                                                                                              | Child-protection, reporting or age-appropriate education content.                                                                           |
| `violence`      | Threats, incitement, glorification and gory detail.                                                                                                              | Neutral news, history, sports, games or non-graphic fiction.                                                                                |
| `hate`          | Hatred of people for a protected characteristic: race, ethnicity, nationality or regional origin, religion, gender, sexual orientation, disability and the like. | Quoting hate to criticise it, and criticism of ideas and governments.                                                                       |
| `harassment`    | Insults, intimidation, bullying, doxxing and pile-ons against a person or group.                                                                                 | Civil disagreement, and blunt criticism of work or ideas.                                                                                   |
| `self-harm`     | Promoting or instructing self-harm, or the writer expressing a current intent.                                                                                   | Prevention, support or recovery content.                                                                                                    |
| `illicit`       | Actionable help to commit a crime, and offers of or requests for illegal goods and services.                                                                     | News, law, harm reduction or security defence.                                                                                              |

OpenAI's six sub-category keys — `harassment/threatening`, `hate/threatening`, `illicit/violent`, `self-harm/intent`, `self-harm/instructions` and `violence/graphic` — are present so that typed SDKs keep working, but they are not judged separately. They are always `false`, with a score of `0` and an empty `category_applied_input_types` list. Do not build rules on them.

## Lines and verdicts

Every judged category has a line: `0.6`, or `0.3` for `sexual/minors`. `categories[k]` is the comparison of that category's score with its line, and `flagged` is `true` when any category's score reaches its line.

| `verdict` | When                                                                                                                                                                                                                | What to do                                 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `block`   | At least one category's score reached its line. It is the same as `flagged: true`.                                                                                                                                  | Reject the text.                           |
| `review`  | No score reached its line, but one is within `0.25` below it and never below `0.15` — from `0.35` for most categories, from `0.15` for `sexual/minors` — or the text is sexually suggestive without being explicit. | Send it to a person or to a stricter rule. |
| `allow`   | Neither of the above.                                                                                                                                                                                               | Let it through.                            |

`sexual_level` says how sexual the text is, independently of the score:

| `sexual_level` | Meaning                                                               |
| -------------- | --------------------------------------------------------------------- |
| `none`         | No sexual content.                                                    |
| `contextual`   | Sex is mentioned without arousing intent: medical, educational, news. |
| `suggestive`   | Sexually suggestive without being explicit.                           |
| `explicit`     | Explicit sexual content.                                              |

`category_scores.sexual` is the probability of the `explicit` level. Suggestive text scores low there and shows up as `sexual_level: "suggestive"` and `verdict: "review"` instead, so read those two fields when suggestive content matters to you.

If you want stricter or looser lines than these, apply your own to `category_scores`.

`policy_version` identifies the policy that produced the scores. Scores from different policy versions are not comparable, so keep the version next to any score you store.

## Limits

* **Text only.** An `image_url` input is refused with a 400.
* **One text per call.** An array with more than one item is refused with a 400 — nothing is partially judged. Send one request per text.
* **At most 8,000 characters.** Longer text is refused with a 400 rather than cut short; split it and send each part. Length is counted in UTF-16 code units, so most emoji count as two.
* **Synchronous.** There is no streaming and no task to poll. Server-side processing takes about 0.2 seconds.

## Errors

Errors use the standard BeatAPI error envelope: `code`, `message`, `retryable`, `request_id`, and `retry_after_seconds` on a 429. See [Errors](/errors) for the full table and the retry policy. On this endpoint the error object also carries OpenAI's `type` and `param` (`param` is always `null`), so the OpenAI SDKs raise their usual exceptions.

```json
{
  "error": {
    "code": "bad_request",
    "message": "input is required: the text to moderate",
    "retryable": false,
    "request_id": "…",
    "type": "invalid_request_error",
    "param": null
  }
}
```

| HTTP status | Code                                  | `type`                  | Python SDK raises       | Meaning                                                                                                                                           |
| ----------: | ------------------------------------- | ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|         400 | `bad_request`                         | `invalid_request_error` | `BadRequestError`       | The message says which rule was broken: empty input, several inputs, an image, over 8,000 characters, an unsupported model name, or invalid JSON. |
|         401 | `missing_api_key` / `invalid_api_key` | `invalid_request_error` | `AuthenticationError`   | No key was sent, or the key is not valid.                                                                                                         |
|         402 | `insufficient_credits`                | `insufficient_quota`    | `APIStatusError`        | Insufficient balance. OpenAI itself answers an empty balance with a 429; here it is a 402, which SDKs do not retry automatically.                 |
|         403 | `forbidden`                           | `invalid_request_error` | `PermissionDeniedError` | The key or account may not use this model. An account that has never topped up cannot use it.                                                     |
|         429 | `rate_limit_exceeded`                 | `rate_limit_error`      | `RateLimitError`        | The request limit was reached. Wait `Retry-After` seconds; the same number is in `retry_after_seconds`.                                           |
|         503 | `processing_unavailable`              | `server_error`          | `InternalServerError`   | The moderation service could not answer. Retry with backoff.                                                                                      |

The exceptions in the table are the ones the official Python SDK (`openai` 2.48.0) raised on 2026-10-12, except the 503 row, which follows the SDK's status mapping and was not triggered.

A failed call judged nothing — never treat an error as "allowed". A request refused with a 400 is not billed.

## Billing

Moderation is billed on **input tokens only**, at **\$0.042 per 1M input tokens**. `beat-moderation-1` is listed on the [pricing page](https://beatapi.io/pricing).

The moderation policy travels with every call and counts as input, about 1,150 tokens, so even a short text has a floor:

| Text                     | Input tokens |        Cost |
| ------------------------ | -----------: | ----------: |
| One line                 |  about 1,200 | ≈ \$0.00005 |
| 8,000 Chinese characters |  about 9,200 |  ≈ \$0.0004 |

`usage.input_tokens` in every response tells you exactly what the call counted, so you can reconcile spend per call.

## Privacy

The text you submit is not written to request logs. Only its length, the scores and the policy version are kept.

## What it does not do

* **It is a classifier, not a guarantee.** Plain violations are caught reliably. Coded slang, homophones and disguised spam — especially in Chinese — are caught less reliably. Test it on your own content before relying on it.
* **`sexual/minors` does not replace hash-matching** for known abuse material, or any legal reporting duty you have.
* **It does not check images, audio or video.**
* **Scores are not exactly repeatable.** In our tests they moved by up to 0.04 between identical calls, so a text that scores close to a line can land on either side of it. Treat a score near a line as borderline.

## Agents

Agents reach the same check through the [Capabilities API](/capabilities), where it is in the catalogue as `model:beat-moderation-1`. The Run input is `{"input": "…"}` and the result is the same body as above. Errors on that path use the standard envelope without `type` and `param`.