Skip to content

The Moderation Endpoint and Why the Model Still Refuses on Its Own

8 min read · updated August 11, 2026

“I ran the moderation endpoint and it came back clean, so why did the model refuse?” Because they are unrelated systems. The moderation endpoint is a classifier you call; the refusal is a behaviour trained into the chat model. Neither is consulted by the other, and nothing propagates a verdict between them.

Two systems that never talk

The moderation endpoint is a separate model at /v1/moderations. You send it text or images, it returns a classification, and it has no memory and no side effects. Calling it does not mark anything, does not tell the chat endpoint anything, and does not affect a later completion request. It is a tool for your policy decisions, and OpenAI documents it as free to use.

The chat model’s refusal is a property of the weights. The same forward pass that could have produced an answer produces a decline instead, because that is what the post-training taught it to do for this input. There is no separate filter running upstream of it in the API response you receive; the refusal text is generated the same way any other text is generated.

Because they were built for different jobs, they disagree constantly — and correctly. A classifier tuned to detect categories of harmful content is not asking “should an assistant help with this?” and a model deciding how to respond is not producing a category label.

What the moderation endpoint returns

curl https://api.openai.com/v1/moderations \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "omni-moderation-latest",
    "input": "Some user-supplied text."
  }'
{
  "id": "modr-...",
  "model": "omni-moderation-latest",
  "results": [
    {
      "flagged": false,
      "categories": {
        "harassment": false,
        "harassment/threatening": false,
        "hate": false,
        "hate/threatening": false,
        "illicit": false,
        "illicit/violent": false,
        "self-harm": false,
        "self-harm/intent": false,
        "self-harm/instructions": false,
        "sexual": false,
        "sexual/minors": false,
        "violence": false,
        "violence/graphic": false
      },
      "category_scores": {
        "harassment": 0.0002461,
        "violence": 0.0000173
      },
      "category_applied_input_types": {
        "violence": ["text"]
      }
    }
  ]
}

Three things about this object. flagged is a summary boolean that is true when any category is true, and it uses OpenAI’s thresholds, not yours. category_scores holds the underlying confidence per category, and it is the field to actually build on: your product may want to hold for review at 0.3 on self-harm/intent even though the boolean is false. And category_applied_input_types exists because the omni model accepts images as well as text, and tells you which modality triggered each category.

The category list has grown over time — the illicit categories arrived with the omni moderation model in 2024, alongside image input. Treat unknown category keys as forward-compatible rather than parsing into a closed enum, and read the current list from OpenAI’s moderation guide.

What a model refusal looks like

There is no dedicated status code. A refusal is a successful 200 with a completion in it, and it can arrive in three shapes:

  • As ordinary content. The most common case: finish_reason: “stop” and a message body that happens to be a decline. Structurally indistinguishable from an answer. This is why refusal-detection by string matching is unreliable and why people build brittle lists of decline phrases.
  • In the refusal field. When you use Structured Outputs, the message carries content: null and a refusal string instead. This field exists because a schema-conforming object has no slot for “no”, and it is the one machine-readable refusal signal the API offers. It is a good reason to use strict mode even where you do not need the schema.
  • As finish_reason: “content_filter”. Generation stopped because content was flagged mid-stream. Distinct from the model choosing to decline, and it means the completion you hold is partial. Every value of that field is worth knowing — the finish_reason values.

The four combinations

Two independent systems produce four outcomes, and the two diagonal ones are where the confusion lives.

  • Clean, and the model answers. The ordinary case.
  • Flagged, and the model refuses. Also unsurprising; your moderation call saved you an inference.
  • Clean, and the model refuses anyway. The question in the title. The categories cover specific harm types; the model’s training covers much more, including instructions it reads as adversarial, requests for advice it treats as regulated, impersonation, and prompts that merely resemble a pattern it was trained to decline. A request to draft a strongly-worded letter to a named person can be entirely clean by the classifier and still draw a decline. Nothing is broken.
  • Flagged, and the model answers. The one that matters for compliance. A classifier is a probabilistic model with a threshold, and the chat model is not consulting it. If you rely on the model declining to enforce your content policy, this quadrant is where that assumption fails, silently, in production. If you have a policy, you have to enforce it — with the moderation call, on your side of the request.

What the endpoint is not

Three jobs people hand the moderation endpoint that it was not built for, each of which fails quietly rather than loudly.

  • It is not a prompt-injection detector. Text instructing your assistant to ignore its instructions and reveal its system prompt is not harmful content by any of the documented categories, and it will come back clean. Injection is an architectural problem — what the model is allowed to do with its tool access — not a content-classification one, and no threshold on these scores will find it.
  • It is not a policy engine. The categories encode OpenAI’s taxonomy of harm, not your terms of service. Spam, competitor mentions, off-topic requests, regulated advice in your jurisdiction and disclosure of your own confidential material are all things you may need to block and none of them is a category here.
  • It is not a compliance record on its own. The response has an id and you should store it with the input hash and your decision. Re-running the endpoint later gives you the current model’s verdict, not the one you acted on, and the model behind omni-moderation-latest is an alias that moves like any other. If the verdict matters after the fact, pin the dated moderation model and keep the result.

How to use both

  1. Moderate the input, before you spend an inference. The endpoint is fast and free; the completion is neither. Screening first is a cost decision as much as a safety one.
  2. Decide on scores, not on the boolean. Keep flagged as your hard block if you like, and add your own lower thresholds per category for the softer actions — hold for review, log, rate-limit the account. The scores are what make the endpoint useful beyond a yes or no.
  3. Moderate the output too, where output is published. Anything that reaches other users — a generated review, a public summary, a support reply — should go through the endpoint on the way out. The model not refusing is not a certification.
  4. Detect refusals structurally. Use strict Structured Outputs so a decline arrives in the refusal field, and check finish_reason on every response. Both are reliable; matching on “I’m sorry” is not, and breaks the first time a model’s phrasing changes.
  5. Log all three signals together — the moderation scores, the finish reason, and whether a refusal field was present. The disagreements between them are the dataset you need to tune your own thresholds, and you cannot reconstruct it afterwards.