# Claude Structured Outputs Refusal Handling: Stop Parsing HTTP 200 Refusals

> Source: <https://dev.to/ssukhpinder/claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals-42bl>
> Published: 2026-08-28 02:04:28+00:00

Claude structured outputs refusal handling belongs before domain deserialization. A successful HTTP exchange only says the API accepted and processed the request; it does not guarantee that the text block contains the JSON object my application expects. Claude can return an HTTP 200 response with `stop_reason: "refusal"`

, and a response stopped by `max_tokens`

can contain incomplete JSON. If I unwrap `content[0].text`

and immediately call `JsonSerializer.Deserialize`

, I turn a documented response state into a misleading parsing failure.

The safer boundary is small: inspect the response envelope, classify the stop reason, and deserialize only a completed structured result.

For the current stable API, I put the JSON Schema under `output_config.format`

. This replaces the earlier beta `output_format`

request shape, and the beta header is no longer required. The [official structured outputs guide](https://platform.claude.com/docs/en/build-with-claude/structured-outputs) documents the current request format and its exceptional cases.

The relevant part of a request looks like this:

```
{
  "output_config": {
    "format": {
      "type": "json_schema",
      "schema": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": ["approve", "escalate"]
          },
          "reason": { "type": "string" }
        },
        "required": ["action", "reason"],
        "additionalProperties": false
      }
    }
  }
}
```

Structured outputs normally give me schema-compliant JSON, but I still treat the envelope as authoritative. A refusal is a valid API response and may not follow my output schema. A `max_tokens`

stop can cut the generated document short. The [stop-reason guidance](https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons) explains why each reason needs deliberate handling instead of a blanket success path.

There is one more subtlety: enum and `const`

text can differ in letter casing. I do not weaken validation into “accept any string.” I normalize casing only while matching against the finite enum declared by my application.

I model decoding as a result, not an exception-driven happy path. The decoder first parses the outer message envelope, reads `stop_reason`

, and rejects refusal or truncation. Only then does it pass the text block to `JsonSerializer.Deserialize`

.

```
using System.Text.Json;
using System.Text.Json.Serialization;

public enum DecodeStatus { Success, Refusal, Truncated, InvalidPayload }
public enum ReviewAction { Approve, Escalate }

public sealed record ReviewWire(
    [property: JsonPropertyName("action")] string Action,
    [property: JsonPropertyName("reason")] string Reason);
public sealed record ReviewResult(ReviewAction Action, string Reason);
public sealed record DecodeResult(DecodeStatus Status, ReviewResult? Value);

public static DecodeResult Decode(string messageJson)
{
    using var message = JsonDocument.Parse(messageJson);
    var root = message.RootElement;
    var stopReason = root.GetProperty("stop_reason").GetString();

    // Gate on the response envelope before touching structured text.
    if (stopReason == "refusal")
        return new(DecodeStatus.Refusal, null);

    if (stopReason == "max_tokens")
        return new(DecodeStatus.Truncated, null);

    if (stopReason != "end_turn" || !TryGetTextBlock(root, out var text))
        return new(DecodeStatus.InvalidPayload, null);

    try
    {
        var wire = JsonSerializer.Deserialize<ReviewWire>(
            text,
            JsonSerializerOptions.Strict);

        if (wire is null || !TryMapAction(wire.Action, out var action))
            return new(DecodeStatus.InvalidPayload, null);

        return new(DecodeStatus.Success, new(action, wire.Reason));
    }
    catch (JsonException)
    {
        return new(DecodeStatus.InvalidPayload, null);
    }
}

static bool TryMapAction(string value, out ReviewAction action)
{
    if (value.Equals("approve", StringComparison.OrdinalIgnoreCase))
    {
        action = ReviewAction.Approve;
        return true;
    }

    if (value.Equals("escalate", StringComparison.OrdinalIgnoreCase))
    {
        action = ReviewAction.Escalate;
        return true;
    }

    action = default;
    return false;
}
```

This order is the key behavior. A refusal containing prose never reaches the inner deserializer. Truncated JSON is classified as truncation, not reported as a random syntax defect. An enum value such as `APPROVE`

maps to the declared `Approve`

member, while an undeclared value still fails.

In the complete sample, `TryGetTextBlock`

scans the content array instead of assuming the first block is text. The outer parser also turns a missing `stop_reason`

, missing text block, or malformed message body into an envelope failure. That separation keeps transport shape, generation outcome, and business data as three observable contracts rather than one catch-all JSON exception.

I keep API transport out of decoder tests. Small response fixtures make the contract deterministic and avoid paid model calls. At minimum, I test four messages: a valid `end_turn`

response, an HTTP 200 refusal with non-schema text, a `max_tokens`

response with truncated JSON, and a completed response whose enum casing differs.

Each test asserts the classification as well as the absence or presence of a domain value. That prevents a later refactor from moving deserialization above the stop-reason gate. It also avoids brittle assertions about generated wording.

I deliberately put invalid inner text in the refusal and truncation fixtures. If a future change parses either payload too early, the verifier fails for the wrong classification immediately. A separate `end_turn`

fixture contains malformed JSON and must produce `InvalidPayload`

; this proves the inner parser still reports a real contract defect when the envelope says generation completed.

The [runnable sample](https://github.com/ssukhpinder/dev-to-code-samples/tree/main/061-claude-structured-refusals) includes those fixtures, source, and test commands. The associated [pull request](https://github.com/ssukhpinder/dev-to-code-samples/pull/51) shows the complete change and validation record.

This decoder is intentionally strict: it accepts `end_turn`

for a request that expects one text result. If I intentionally use stop sequences, tool calls, or streaming, I need a state machine and an allowlist designed for those response paths. I would not silently treat every unfamiliar stop reason as success.

I also would not automatically retry a refusal. A refusal is not a transport outage, and retrying the same request can waste capacity without changing the outcome. For `max_tokens`

, a caller can choose to reduce the requested structure or adjust its token budget, but that policy belongs above the decoder.

Finally, case-insensitive enum matching is appropriate only when casing is not meaningful in the domain. The declared values remain the boundary; normalization should never turn arbitrary model text into an accepted business decision.

Where does your integration currently check `stop_reason`

: before deserialization, or only after parsing fails?

Happy coding!
