Skip to content
tutorial

Convert any JSON response to a Zod schema with one POST request

| 5 min read
TypeScript code in a VS Code editor
Photo by Safar Safarov on Unsplash

You hit a third-party API, get a JSON response, and now you need a Zod schema for it. The manual process: stare at the response, count the fields, figure out which ones are nullable, handle nested objects, type out z.object and z.string() for every property. One typo and your validation silently passes bad data.

For a flat five-field object, this takes a few minutes. For a Stripe payment intent with nested charges, metadata, and 30+ fields, it takes long enough that you start questioning your career choices.

The botoi /v1/schema/json-to-zod endpoint eliminates this. POST any JSON, get a complete Zod schema back. One request, no CLI to install, no npm package to configure.

The API call

Send a JSON object and an optional schema name:

curl

curl -X POST https://api.botoi.com/v1/schema/json-to-zod \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "id": "pi_3abc123",
      "amount": 4999,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_xyz789"
    },
    "name": "PaymentIntent"
  }'

Response:

{
  "success": true,
  "data": {
    "zod": "const PaymentIntentSchema = z.object({\n  id: z.string(),\n  amount: z.number(),\n  currency: z.string(),\n  status: z.string(),\n  customer: z.string()\n})",
    "name": "PaymentIntent"
  }
}
JavaScript code with type annotations visible
Photo by Florian Olivo on Unsplash

Node.js

const response = await fetch("https://api.botoi.com/v1/schema/json-to-zod", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    json: {
      id: "pi_3abc123",
      amount: 4999,
      currency: "usd",
      status: "succeeded",
      customer: "cus_xyz789",
    },
    name: "PaymentIntent",
  }),
});

const result = await response.json();
console.log(result.data.zod);

Python

import requests

response = requests.post(
    "https://api.botoi.com/v1/schema/json-to-zod",
    json={
        "json": {
            "id": "pi_3abc123",
            "amount": 4999,
            "currency": "usd",
            "status": "succeeded",
            "customer": "cus_xyz789",
        },
        "name": "PaymentIntent",
    },
)

print(response.json()["data"]["zod"])

The endpoint accepts any valid JSON in the json field. Objects, arrays, deeply nested structures; it all works. The name field is optional and defaults to "Root".

Real example: a Stripe payment intent

Here is a realistic Stripe payment_intent response with nested metadata and charges objects. This is the kind of payload where hand-writing Zod schemas gets painful fast.

Request body:

{
  "json": {
    "id": "pi_3PqRsT4eZvKYlo2C0",
    "object": "payment_intent",
    "amount": 24900,
    "amount_received": 24900,
    "currency": "usd",
    "status": "succeeded",
    "payment_method": "pm_1PqRsT4eZvKYlo2C",
    "customer": "cus_QrStUvWxYz",
    "metadata": {
      "order_id": "ord_98765",
      "product_sku": "PRO-ANNUAL"
    },
    "created": 1711459200,
    "livemode": false,
    "charges": {
      "data": [
        {
          "id": "ch_3PqRsT4eZvKYlo2C0",
          "amount": 24900,
          "status": "succeeded",
          "receipt_url": "https://pay.stripe.com/receipts/abc123"
        }
      ]
    }
  },
  "name": "StripePaymentIntent"
}

The API returns this Zod schema:

const StripePaymentIntentSchema = z.object({
  id: z.string(),
  object: z.string(),
  amount: z.number(),
  amount_received: z.number(),
  currency: z.string(),
  status: z.string(),
  payment_method: z.string(),
  customer: z.string(),
  metadata: z.object({
    order_id: z.string(),
    product_sku: z.string(),
  }),
  created: z.number(),
  livemode: z.boolean(),
  charges: z.object({
    data: z.array(
      z.object({
        id: z.string(),
        amount: z.number(),
        status: z.string(),
        receipt_url: z.string(),
      })
    ),
  }),
})

Every nested object becomes its own z.object. The charges.data array produces a z.array with the correct item shape. Booleans, numbers, and strings are detected from the values. Copy this into your codebase, add import z from "zod", and you have runtime-validated types for Stripe responses in under 30 seconds.

Also works for TypeScript interfaces

If you need TypeScript types without runtime validation, the /v1/schema/json-to-typescript endpoint generates interfaces from the same JSON input.

curl -X POST https://api.botoi.com/v1/schema/json-to-typescript \
  -H "Content-Type: application/json" \
  -d '{
    "json": {
      "id": "pi_3abc123",
      "amount": 4999,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_xyz789"
    },
    "name": "PaymentIntent"
  }'

Response:

{
  "success": true,
  "data": {
    "typescript": "interface PaymentIntent {\n  id: string;\n  amount: number;\n  currency: string;\n  status: string;\n  customer: string;\n}",
    "name": "PaymentIntent"
  }
}

Same input format, same name parameter. Use json-to-zod when you need runtime validation (API handlers, form parsing, webhook payloads). Use json-to-typescript when you only need compile-time type safety.

Build a codegen script for your project

The real power shows up when you automate schema generation. This script fetches live API responses, converts each one to a Zod schema, and writes the output to your src/schemas/ directory.

#!/bin/bash
set -euo pipefail

API_BASE="https://api.botoi.com/v1"
OUTPUT_DIR="./src/schemas"

mkdir -p "$OUTPUT_DIR"

generate_schema() {
  local name=$1
  local url=$2
  local output_file="$OUTPUT_DIR/$(echo "$name" | tr '[:upper:]' '[:lower:]').ts"

  echo "Fetching $url ..."
  local json_response
  json_response=$(curl -s "$url")

  echo "Generating Zod schema for $name ..."
  local zod_response
  zod_response=$(curl -s -X POST "$API_BASE/schema/json-to-zod" \
    -H "Content-Type: application/json" \
    -d "{
      \"json\": $json_response,
      \"name\": \"$name\"
    }")

  local schema
  schema=$(echo "$zod_response" | jq -r '.data.zod')

  cat > "$output_file" << SCHEMAEOF
import { z } from "zod";

$schema

export type $name = z.infer;
SCHEMAEOF

  echo "Wrote $output_file"
}

# Add your API endpoints here
generate_schema "UserProfile" "https://api.example.com/users/1"
generate_schema "Order" "https://api.example.com/orders/latest"
generate_schema "Product" "https://api.example.com/products/42"

echo "Done. Generated $(ls "$OUTPUT_DIR"/*.ts | wc -l) schema files."

Running it:

Fetching https://api.example.com/users/1 ...
Generating Zod schema for UserProfile ...
Wrote ./src/schemas/userprofile.ts
Fetching https://api.example.com/orders/latest ...
Generating Zod schema for Order ...
Wrote ./src/schemas/order.ts
Fetching https://api.example.com/products/42 ...
Generating Zod schema for Product ...
Wrote ./src/schemas/product.ts
Done. Generated 3 schema files.

Each generated file looks like this:

import { z } from "zod";

const UserProfileSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string(),
  avatar_url: z.string(),
  plan: z.string(),
  created_at: z.string(),
})

export type UserProfile = z.infer<typeof UserProfileSchema>;

Add this script to your package.json as "codegen:schemas" and run it whenever the upstream API changes. Your Zod schemas stay in sync with the real response shape, and TypeScript types are derived from the schema automatically.

When this is useful

  • Onboarding a new third-party API. Hit the API once, convert the response to a Zod schema, and start building with validated types instead of guessing field names.
  • Migrating JavaScript to TypeScript. If you have API responses flowing through untyped code, generate schemas from real data to get type coverage fast.
  • Keeping schemas in sync. Run the codegen script in CI on a schedule to detect when an upstream API changes its response shape.
  • Prototyping. When you need validated types for a proof of concept and do not want to spend time hand-crafting schemas for APIs you might discard next week.

Frequently asked questions

Do I need an API key to use the JSON to Zod endpoint?
No. The free tier allows anonymous access at 5 requests per minute with IP-based rate limiting. You can generate Zod schemas without signing up. For higher volume or CI pipelines, add an API key to the Authorization header.
Can I set a custom schema name instead of "Root"?
Yes. Pass a "name" field in the request body. For example, setting "name": "PaymentIntent" will produce "const PaymentIntentSchema = z.object({...})". If you omit the name field, it defaults to "Root".
Does the API handle nested objects and arrays?
Yes. The endpoint recursively processes nested objects (z.object), arrays (z.array), and mixed-type arrays (z.union). It handles null values with z.nullable and optional fields correctly.
What is the difference between json-to-zod and json-to-typescript?
The json-to-zod endpoint produces a Zod schema string that you can import and use for runtime validation. The json-to-typescript endpoint produces a TypeScript interface for compile-time type checking only. Use Zod when you need both types and runtime validation; use TypeScript interfaces when you only need compile-time safety.
Can I use this in a CI pipeline to auto-generate schemas from API responses?
Yes. Write a script that fetches a live API response, POSTs the JSON to the botoi endpoint, and writes the output to a file in your codebase. Run the script as a CI step or a pre-commit hook to keep your schemas in sync with the API.

Try this API

JSON Validator API — interactive playground and code examples

More tutorial posts

Start building with botoi

150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.