← Blog

Claude API Response Validation with Zod

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

When you call the Claude API, you get back a JSON object whose shape is mostly stable but not guaranteed at every field — content blocks vary by type, tool use arguments are model-generated, and streaming events arrive as partial fragments. If your code assumes a fixed shape and just does response.content[0].text, one unexpected block type or empty array will crash your app in production. Zod solves this by giving you runtime schema validation with full TypeScript inference, so you catch malformed or unexpected responses at the boundary instead of three function calls deep.

This article shows how to build Zod schemas for Claude API responses, validate both plain text and tool-use outputs, and handle the parts of the response that are inherently unpredictable — like model-generated JSON inside tool calls.

Why validate Claude API responses at all

TypeScript types only exist at compile time. The Anthropic SDK's type definitions tell you what the response should look like, but they don't check what actually came back over the wire. Two real scenarios where this bites:

Zod lets you parse the response, get a typed and guaranteed-correct object back, or fail fast with a readable error instead of a silent undefined.

Basic schema for a Claude response

Start with the top-level response shape. This mirrors what you get from the Messages API, whether you're calling Anthropic directly or through a proxy like SubToAPI.

import { z } from "zod";

const TextBlock = z.object({
  type: z.literal("text"),
  text: z.string(),
});

const ToolUseBlock = z.object({
  type: z.literal("tool_use"),
  id: z.string(),
  name: z.string(),
  input: z.record(z.unknown()),
});

const ContentBlock = z.union([TextBlock, ToolUseBlock]);

const ClaudeResponse = z.object({
  id: z.string(),
  model: z.string(),
  role: z.literal("assistant"),
  content: z.array(ContentBlock).min(1),
  stop_reason: z.enum([
    "end_turn",
    "max_tokens",
    "stop_sequence",
    "tool_use",
  ]),
  usage: z.object({
    input_tokens: z.number(),
    output_tokens: z.number(),
  }),
});

type ClaudeResponse = z.infer<typeof ClaudeResponse>;

Now parsing is a single call:

const raw = await fetch("https://api.anthropic.com/v1/messages", {
  method: "POST",
  headers: {
    "x-api-key": process.env.ANTHROPIC_API_KEY,
    "anthropic-version": "2023-06-01",
    "content-type": "application/json",
  },
  body: JSON.stringify(payload),
}).then((r) => r.json());

const result = ClaudeResponse.safeParse(raw);

if (!result.success) {
  console.error(result.error.format());
  throw new Error("Unexpected Claude response shape");
}

const message = result.data; // fully typed, guaranteed valid

Using safeParse instead of parse avoids throwing inside a hot path — you decide how to log or recover from a validation failure instead of an uncaught exception taking down a request.

Validating tool-use arguments specifically

The riskiest part of any Claude integration is trusting input on a tool_use block, since that object is generated by the model, not by your code. Define a schema per tool and validate it separately from the envelope:

const CreateTicketInput = z.object({
  title: z.string().min(1),
  priority: z.enum(["low", "medium", "high"]),
  assignee: z.string().email().optional(),
});

function handleToolUse(block) {
  const parsed = CreateTicketInput.safeParse(block.input);
  if (!parsed.success) {
    // Return this back to Claude as a tool_result error instead of crashing
    return { error: parsed.error.flatten() };
  }
  return createTicket(parsed.data);
}

This pattern also lets you feed validation errors back into the conversation as a tool_result with is_error: true, so the model can retry with corrected arguments instead of your process failing silently. See /docs/tools for the tool-result format if you're wiring this into a multi-turn agent loop.

Handling streaming events

Streaming responses arrive as a sequence of SSE events (message_start, content_block_delta, message_stop, etc.), each with its own shape. Validate each event type independently rather than trying to force one giant schema:

const ContentBlockDelta = z.object({
  type: z.literal("content_block_delta"),
  index: z.number(),
  delta: z.object({
    type: z.literal("text_delta"),
    text: z.string(),
  }),
});

function handleEvent(event) {
  if (event.type === "content_block_delta") {
    const parsed = ContentBlockDelta.parse(event);
    process.stdout.write(parsed.delta.text);
  }
  // handle other event types with their own schemas
}

If you're consuming streamed output through a wrapper API, the event structure should already be normalized — SubToAPI, for example, exposes the same SSE format described in /docs/streaming, so the same Zod schemas work whether you're calling the raw provider or a proxied endpoint.

Structured output as a shortcut

If you control the prompt, you can ask Claude to always return a specific JSON structure and then validate that structure directly with the same tool schema, rather than parsing free text. This is more reliable than regex-extracting JSON from a text block and pairs naturally with Zod:

const responseText = message.content.find((b) => b.type === "text")?.text;
const parsedJson = JSON.parse(responseText);
const validated = MyOutputSchema.parse(parsedJson);

Wrap the JSON.parse in a try/catch — models occasionally wrap JSON in markdown fences or add a trailing sentence, and you want that to surface as a validation error, not an unhandled exception.

Where this fits with a wrapper API

If you're routing Claude calls through SubToAPI to get a stable HTTPS endpoint, application-scoped keys, and usage metadata across a team, the response envelope from /v1/messages follows the same Messages API shape, so every schema in this article works unchanged — you're validating the same content, stop_reason, and usage fields. Check /docs/messages for the exact response reference, or /docs/quickstart if you're setting up a key for the first time.

Practical checklist

questions

Does Zod slow down request handling noticeably? No — Zod validation on a single API response object takes well under a millisecond. It's negligible compared to the network round trip to the model.

Should I validate on every request or only in development? Validate in every environment. Production is exactly where an unexpected response shape (rate limit body, proxy error page, malformed tool input) will cost you the most if it goes unhandled.

Can I generate Zod schemas automatically from a tool's JSON Schema? There are libraries that convert JSON Schema to Zod, but for tool inputs it's usually cleaner to hand-write the Zod schema alongside the tool definition — you get better error messages and can add refinements JSON Schema doesn't express well, like cross-field validation.

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 →