{"slug": "claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals", "title": "Claude Structured Outputs Refusal Handling: Stop Parsing HTTP 200 Refusals", "summary": "A developer detailed a robust pattern for handling Claude structured outputs, emphasizing that an HTTP 200 response does not guarantee valid JSON and that applications must inspect the response envelope's stop_reason before deserialization. The approach classifies refusals and truncations as distinct states, preventing misleading parsing failures. The developer also noted the current API uses output_config.format for JSON Schema and recommended normalizing enum casing without weakening validation.", "body_md": "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\"`\n\n, and a response stopped by `max_tokens`\n\ncan contain incomplete JSON. If I unwrap `content[0].text`\n\nand immediately call `JsonSerializer.Deserialize`\n\n, I turn a documented response state into a misleading parsing failure.\n\nThe safer boundary is small: inspect the response envelope, classify the stop reason, and deserialize only a completed structured result.\n\nFor the current stable API, I put the JSON Schema under `output_config.format`\n\n. This replaces the earlier beta `output_format`\n\nrequest 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.\n\nThe relevant part of a request looks like this:\n\n```\n{\n  \"output_config\": {\n    \"format\": {\n      \"type\": \"json_schema\",\n      \"schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"action\": {\n            \"type\": \"string\",\n            \"enum\": [\"approve\", \"escalate\"]\n          },\n          \"reason\": { \"type\": \"string\" }\n        },\n        \"required\": [\"action\", \"reason\"],\n        \"additionalProperties\": false\n      }\n    }\n  }\n}\n```\n\nStructured 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`\n\nstop 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.\n\nThere is one more subtlety: enum and `const`\n\ntext 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.\n\nI model decoding as a result, not an exception-driven happy path. The decoder first parses the outer message envelope, reads `stop_reason`\n\n, and rejects refusal or truncation. Only then does it pass the text block to `JsonSerializer.Deserialize`\n\n.\n\n```\nusing System.Text.Json;\nusing System.Text.Json.Serialization;\n\npublic enum DecodeStatus { Success, Refusal, Truncated, InvalidPayload }\npublic enum ReviewAction { Approve, Escalate }\n\npublic sealed record ReviewWire(\n    [property: JsonPropertyName(\"action\")] string Action,\n    [property: JsonPropertyName(\"reason\")] string Reason);\npublic sealed record ReviewResult(ReviewAction Action, string Reason);\npublic sealed record DecodeResult(DecodeStatus Status, ReviewResult? Value);\n\npublic static DecodeResult Decode(string messageJson)\n{\n    using var message = JsonDocument.Parse(messageJson);\n    var root = message.RootElement;\n    var stopReason = root.GetProperty(\"stop_reason\").GetString();\n\n    // Gate on the response envelope before touching structured text.\n    if (stopReason == \"refusal\")\n        return new(DecodeStatus.Refusal, null);\n\n    if (stopReason == \"max_tokens\")\n        return new(DecodeStatus.Truncated, null);\n\n    if (stopReason != \"end_turn\" || !TryGetTextBlock(root, out var text))\n        return new(DecodeStatus.InvalidPayload, null);\n\n    try\n    {\n        var wire = JsonSerializer.Deserialize<ReviewWire>(\n            text,\n            JsonSerializerOptions.Strict);\n\n        if (wire is null || !TryMapAction(wire.Action, out var action))\n            return new(DecodeStatus.InvalidPayload, null);\n\n        return new(DecodeStatus.Success, new(action, wire.Reason));\n    }\n    catch (JsonException)\n    {\n        return new(DecodeStatus.InvalidPayload, null);\n    }\n}\n\nstatic bool TryMapAction(string value, out ReviewAction action)\n{\n    if (value.Equals(\"approve\", StringComparison.OrdinalIgnoreCase))\n    {\n        action = ReviewAction.Approve;\n        return true;\n    }\n\n    if (value.Equals(\"escalate\", StringComparison.OrdinalIgnoreCase))\n    {\n        action = ReviewAction.Escalate;\n        return true;\n    }\n\n    action = default;\n    return false;\n}\n```\n\nThis 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`\n\nmaps to the declared `Approve`\n\nmember, while an undeclared value still fails.\n\nIn the complete sample, `TryGetTextBlock`\n\nscans the content array instead of assuming the first block is text. The outer parser also turns a missing `stop_reason`\n\n, 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.\n\nI 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`\n\nresponse, an HTTP 200 refusal with non-schema text, a `max_tokens`\n\nresponse with truncated JSON, and a completed response whose enum casing differs.\n\nEach 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.\n\nI 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`\n\nfixture contains malformed JSON and must produce `InvalidPayload`\n\n; this proves the inner parser still reports a real contract defect when the envelope says generation completed.\n\nThe [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.\n\nThis decoder is intentionally strict: it accepts `end_turn`\n\nfor 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.\n\nI 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`\n\n, a caller can choose to reduce the requested structure or adjust its token budget, but that policy belongs above the decoder.\n\nFinally, 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.\n\nWhere does your integration currently check `stop_reason`\n\n: before deserialization, or only after parsing fails?\n\nHappy coding!", "url": "https://wpnews.pro/news/claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals", "canonical_source": "https://dev.to/ssukhpinder/claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals-42bl", "published_at": "2026-08-28 02:04:28+00:00", "updated_at": "2026-08-28 02:18:39.058983+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools"], "entities": ["Claude", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals", "markdown": "https://wpnews.pro/news/claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals.md", "text": "https://wpnews.pro/news/claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals.txt", "jsonld": "https://wpnews.pro/news/claude-structured-outputs-refusal-handling-stop-parsing-http-200-refusals.jsonld"}}