← Blog

Claude Streaming Refusals: Detection and Handling

2026-09-14 · 5 min read · SubToAPI Team

When you stream a response from Claude and the model decides mid-generation that it shouldn't continue — because the request touches something disallowed, ambiguous, or requires more context — you get a streaming refusal: a partial or truncated stream that ends differently from a normal completion. If your app isn't built to detect this, users see a cut-off sentence, a spinner that never resolves, or a broken UI state instead of a clear message.

This article covers how Claude signals refusals during streaming, how to detect them programmatically, and how to build UX that handles them without confusing your users.

How refusals show up in a stream

Unlike a hard API error, a refusal is usually a normal successful response with content that declines the request, plus a stop_reason that tells you why generation ended. In streaming mode, you get this information at the end of the event sequence, after the content_block_delta events, in the final message_delta event.

The stop reasons you'll typically see:

The tricky part: most refusals are just end_turn with refusal text, not a distinct machine-readable flag. Claude doesn't emit a special stop_reason: "refusal" — it declines in natural language, streamed like any other response. That means your detection logic has to look at content, not just metadata, unless you're also handling structural signals like an unusually short response or a stream that ends much earlier than expected.

Detecting a refusal during or after streaming

There's no single boolean flag to check, so practical detection combines a few signals:

  1. Response length relative to the prompt. A refusal is often much shorter than a normal answer to the same kind of request. If you expect a 500-word explanation and get 20 words, that's worth flagging.
  2. Common refusal phrasing. Claude's refusals tend to use recognizable patterns: "I can't help with that," "I'm not able to," "I won't provide," "This request involves... which I can't assist with." A lightweight regex or keyword check on the first chunk of content catches most cases.
  3. Stop reason plus low token count. If stop_reason is end_turn and output_tokens in the final usage block is very low compared to typical responses for that endpoint, treat it as a likely refusal and route it differently in your UI.

Here's a minimal pattern for catching refusals as you consume a stream:

const refusalPatterns = [
  /i can'?t help with/i,
  /i'?m not able to/i,
  /i won'?t (provide|generate|write)/i,
  /this request (involves|violates)/i,
];

let buffer = "";

for await (const event of stream) {
  if (event.type === "content_block_delta") {
    buffer += event.delta.text;
  }
  if (event.type === "message_delta" && event.delta.stop_reason) {
    const looksLikeRefusal =
      buffer.length < 200 &&
      refusalPatterns.some((re) => re.test(buffer));

    if (looksLikeRefusal) {
      handleRefusal(buffer);
    } else {
      finalizeResponse(buffer);
    }
  }
}

This isn't perfect — false positives happen when a legitimate short answer starts with "I can't" for unrelated reasons — but combined with length heuristics it's reliable enough for production UX decisions like showing a "this request needs rephrasing" message instead of rendering the raw text as if it were a normal answer.

Handling refusals gracefully in your UI

Once you detect a likely refusal, don't just display the raw model text and move on. A few patterns that work well:

Refusals vs. actual streaming errors

It's worth separating refusals from genuine stream failures, since they need different handling:

| Situation | Signal | Action | |---|---|---| | Refusal | stop_reason: end_turn, short content matching refusal patterns | Show refusal UI, offer rephrase | | Rate limit | HTTP 429 before or during stream | Backoff and retry | | Connection drop | Stream closes without a message_stop event | Reconnect, resume or restart request | | Max tokens hit | stop_reason: max_tokens | Continue generation or increase token limit |

If you're building on SubToAPI rather than calling Claude directly, streaming responses come through the same event format via the /v1/messages endpoint with "stream": true, so this detection logic works unchanged — you just point requests at your sub_live_... API key instead of managing provider credentials directly. See the streaming docs for the exact event shapes and the messages docs for stop reason details.

A note on prompt design to reduce refusals

Some refusals are avoidable with better prompt structure:

None of this eliminates refusals entirely — nor should it, since some are correct behavior — but it reduces false-positive refusals triggered by ambiguous phrasing.

questions

Does Claude return a special error code for refusals? No. Refusals typically come back as a normal successful response with stop_reason: end_turn and plain-text content declining the request. You detect them by inspecting content and length, not by checking for a distinct error.

Can I stop a refusal from rendering halfway through? You can buffer the first chunk or two of a stream, run it through refusal-pattern detection, and switch your UI treatment before rendering further deltas — but you can't prevent the model from generating refusal text in the first place except through better prompting.

Should I retry automatically after a refusal? Generally no. Retrying the identical prompt usually produces the same refusal. Adjust the prompt — add context, narrow the request, remove ambiguous phrasing — before resending.

Turn your Claude access into an HTTPS API

SubToAPI gives you application API keys, streaming, tool use and usage insights on top of your existing Claude access — set up in minutes.

Start free  Read the quickstart →