Skip to content

What a Declined Response Looks Like in the Claude API

8 min read · updated August 11, 2026

A declined request does not throw. It returns HTTP 200 with a well-formed message body, and the only thing distinguishing it from a successful answer is a field most integrations never read.

It is a success, not an error

There is a real distinction here and it is easy to conflate. A malformed request is a 400 with an error object. A rate limit is a 429. A decline is neither: the request was valid, it was processed, and the model or a safety classifier in front of it declined to produce the content. That is a normal outcome of a valid request, so it comes back as a 200.

The consequence is that an SDK will not raise, a try/except block will not catch it, and a status-code check will pass. The only signal is stop_reason, and the failure mode in the wild is code that reads content[0].text immediately — which on a decline is either the wrong thing or an index error into an empty array.

The shape, beside a normal completion

A completed answer:

{
  "id": "msg_01Ab…",
  "type": "message",
  "role": "assistant",
  "model": "claude-opus-4-6",
  "content": [
    {"type": "text", "text": "Here is the rollback procedure…"}
  ],
  "stop_reason": "end_turn",
  "stop_sequence": null,
  "stop_details": null,
  "usage": {"input_tokens": 214, "output_tokens": 186}
}

A decline, same endpoint, same status code:

{
  "id": "msg_01Cd…",
  "type": "message",
  "role": "assistant",
  "model": "claude-opus-4-6",
  "content": [],
  "stop_reason": "refusal",
  "stop_sequence": null,
  "stop_details": {
    "type": "refusal",
    "category": "cyber",
    "explanation": "…"
  },
  "usage": {"input_tokens": 214, "output_tokens": 0}
}

Three differences. stop_reason is the literal string "refusal" rather than "end_turn". content is empty when the decline happened before any output — not a text block containing an apology, which is what a model-authored refusal looks like and is a different thing entirely. And stop_details, null on every other outcome, is populated.

Note that a decline before any generation costs nothing in output tokens. Whether the input is billed depends on the provider’s current policy, but the response makes the split visible in usage either way.

The stop_details object

stop_details exists only for this outcome and carries a category naming the policy area that fired, plus an explanation. The category set is open — it grows as new classifiers are added — so treat it as a string you route on rather than an enum you exhaustively match, and expect it to be null sometimes.

The reason to read it rather than ignore it is that categories differ in what a sensible retry looks like. A decline on a research-adjacent category on one model may be answered by a different model in the same family; a decline that is genuinely about disallowed content will be declined everywhere and retrying is pure cost. Branching on category is how you tell those apart without guessing.

Refusal categories, and which models run which classifiers, change between model releases — this is one of the most volatile parts of the API surface. Read the current set from Anthropic’s API documentation and never hard-code an exhaustive match.

The decline stop_reason cannot see

This is the distinction that matters most in practice, and the field does not help you with it. There are two entirely different things people call a refusal.

The first is the one above: a classifier decision, surfaced as stop_reason: "refusal" with structured details. It is machine-detectable, and the code in the last section handles it.

The second is the model declining in prose. It writes “I am not able to help with that”, finishes the sentence, and stops normally. That response carries stop_reason: "end_turn", a populated content array, and stop_details: null. Structurally it is indistinguishable from a successful answer, because structurally it is one — the model was asked a question and produced a reply.

{
  "content": [
    {"type": "text",
     "text": "I can't help with writing credential-stuffing tooling, but I'm happy
              to help with rate-limiting or account-lockout design instead."}
  ],
  "stop_reason": "end_turn",
  "stop_details": null
}

No field distinguishes this from an answer. If your pipeline feeds the text into a JSON parser, a downstream tool, or a diff, it will fail on the content rather than on the outcome — and the error you get is a parse error thousands of lines from the cause.

The instinct is to detect it with string matching on phrases like “I cannot” or “I am unable”. This does not work and is worth understanding why. It has false positives — a model explaining that a library cannot do something matches the same patterns — and false negatives, because the phrasing is not fixed and varies by model, by language and by release. A brittle matcher on top of prose is a worse detector than no detector, because it produces confident wrong answers in both directions.

What works is not detecting the refusal at all, but validating the result you needed. If you expected JSON, parse it and treat the failure as an unusable response. If you expected a tool call, check that stop_reason was "tool_use". If you expected a patch, check it applies. Validating the shape you asked for catches a prose decline, a truncation and a model that simply misunderstood, all through one branch — which is the right number of branches for “I did not get what I needed”.

Refusals that arrive mid-stream

A decline can also happen after generation has begun. On a streamed request this means you have already delivered text to the user when the stream ends with a refusal stop_reason on the message_delta event.

event: content_block_delta
data: {"type":"content_block_delta","index":0,
       "delta":{"type":"text_delta","text":"The first step is to"}}

event: message_delta
data: {"type":"message_delta",
       "delta":{"stop_reason":"refusal"},
       "usage":{"output_tokens":9}}

The partial output is real and was generated, and it is usually charged. The right behaviour is to discard it rather than present a truncated fragment as an answer, which means a streaming UI needs a way to retract what it has already rendered. That is worth building before you need it.

Handling it without breaking

The whole of the defensive pattern is to check the field before touching the content:

const msg = await client.messages.create({ model, max_tokens: 4096, messages });

switch (msg.stop_reason) {
  case "refusal":
    // content may be empty; stop_details may be null. Do not index blindly.
    return handleDecline(msg.stop_details?.category ?? null);
  case "max_tokens":
    return handleTruncated(msg);       // incomplete, not declined
  case "tool_use":
    return runTools(msg);
  default:
    return render(msg.content);
}
  • Do not retry the identical request. A classifier decision is not transient. The same bytes will be declined again, and the retry is a cost with no chance of a different outcome.
  • Do not present it as an outage. A decline is a deliberate answer, and telling the user the service is down when it is not produces support tickets that cannot be resolved.
  • Log the category. A cluster of declines in one category on one route is usually a prompt problem you can fix, and it is invisible if you only log “request failed”.