# How To Generate an OpenAPI Document With Zod v4

# How to generate an OpenAPI document with Zod v4

<Callout title="Zod Version" type="info">
  This guide covers Zod v4. For Zod v3, see the [Zod v3
  guide](/openapi/frameworks/zod-v3).
</Callout>

Zod is a powerful and flexible schema validation library for TypeScript, which many developers use to define their TypeScript data parsing schemas.

This tutorial demonstrates how to use another TypeScript library, the zod-openapi NPM package, to convert Zod schemas into a complete OpenAPI document, and then how to use Speakeasy to generate a production-ready SDK from that document.

## Why use Zod with OpenAPI?

Combining Zod with OpenAPI generation offers the best of both worlds: runtime validation and automatic API documentation. Instead of writing schemas twice - once for runtime validation and again for your OpenAPI document - you define your data models once in Zod and generate both TypeScript types and OpenAPI documentation from the same source.

This eliminates the task of keeping hand-written OpenAPI documents in sync with your actual API implementation. When paired with Speakeasy's SDK generation, you get type-safe client libraries that automatically stay up to date with your API changes.

## Step-by-step tutorial: From Zod to OpenAPI to SDK

Now let's walk through the process of generating an OpenAPI document and SDK for our Burgers and Orders API.

### Requirements

This tutorial assumes basic familiarity with TypeScript and Node.js development.

The following should be installed on your machine:

- [Node.js version 20 or above](https://nodejs.org/en/download).
- The [Speakeasy CLI](/docs/introduction#install-the-speakeasy-cli), which we'll use to generate an SDK from the OpenAPI document.

### Create a Zod project

The source code for our complete example is available in the
[`speakeasy-api/examples`](https://github.com/speakeasy-api/examples.git)
repository in the `zod-openapi` directory. The project contains a
pre-generated Python SDK with instructions on how to generate more SDKs. You
can clone this repository to test how changes to the Zod schema definition
result in changes to the generated SDK.

Start by cloning the `speakeasy-api/examples` repository.

```bash filename="Terminal"
git clone https://github.com/speakeasy-api/examples.git
cd zod-openapi
npm install
```

<Callout title="Alternative Setup" type="info">
Alternatively, initialize a new NPM project and install the required dependencies, and try to implement the suggested steps in this tutorial:

```
npm init -y
npm install zod@^4.0.0 yaml zod-openapi
```

</Callout>

### Installing TypeScript development tools

For this tutorial, we'll use `tsx` for running TypeScript directly:

```bash filename="Terminal"
npm install -D tsx
```

### Create the first Zod schema

Save this TypeScript code in a new file called `index.ts`. Note the dual import strategy:

```typescript filename="index.ts"

const burgerSchema = zod.object({
  id: zod.number().min(1),
  name: zod.string().min(1).max(50),
  description: zod.string().max(255).optional(),
});
```

### Extending Zod with OpenAPI

```typescript filename="index.ts"
const burgerSchema = zod
  .object({
    id: zod.number().min(1).meta({
      description: "The unique identifier of the burger.",
      example: 1,
    }),
    name: zod.string().min(1).max(50).meta({
      description: "The name of the burger.",
      example: "Veggie Burger",
    }),
    description: zod.string().max(255).optional().meta({
      description: "The description of the burger.",
      example: "A delicious bean burger with avocado.",
    }),
  })
  .meta({
    description: "A burger served at the restaurant.",
  });
```

### Reusing schemas with references

To avoid duplication and promote reuse, we can define reusable schemas for common fields. For example, we can define a `BurgerIdSchema` for the burger ID field and use it in the `burgerSchema`.

```typescript filename="index.ts"
// Define a reusable BurgerId schema
const BurgerIdSchema = zod.number().min(1).meta({
  description: "The unique identifier of the burger.",
  example: 1,
  readOnly: true,
});

const burgerSchema = zod
  .object({
    id: BurgerIdSchema, // Use the BurgerIdSchema
    name: zod.string().min(1).max(50).meta({
      description: "The name of the burger.",
      example: "Veggie Burger",
    }),
    description: zod.string().max(255).optional().meta({
      description: "The description of the burger.",
      example: "A delicious bean burger with avocado.",
    }),
  })
  .meta({
    description: "A burger served at the restaurant.",
  });
```

### Generating an OpenAPI document

Now that the Zod schemas are defined with OpenAPI metadata, it's time to generate an OpenAPI document.

For this two imports are needed from the `zod-openapi` package: `ZodOpenApiOperationObject` and `createDocument`.

```typescript filename="index.ts"

```

The `createDocument` method will help generate an OpenAPI document. Pass in the `burgerSchema` and a title for the document.

```typescript filename="index.ts"
const document = createDocument({
  openapi: "3.1.0",
  info: {
    title: "Burger Restaurant API",
    description: "An API for managing burgers and orders at a restaurant.",
    version: "1.0.0",
  },
  servers: [
    {
      url: "https://example.com",
      description: "The production server.",
    },
  ],
  components: {
    schemas: {
      burgerSchema,
    },
  },
});

console.log(yaml.stringify(document));
```

### Varying read/write schemas

One common pattern in OpenAPI documents is to have separate schemas for creating and updating resources. This allows you to define different validation rules for these operations. That would look sometime like this:

```typescript filename="index.ts"
const burgerCreateSchema = burgerSchema.omit({ id: true }).meta({
  description: "A burger to create.",
});
```

An easier approach is to utilize `readOnly` and `writeOnly` properties in OpenAPI. Marking the `id` field as `readOnly` indicates that it is only returned in responses and not expected in requests.

```typescript filename="index.ts"
const BurgerIdSchema = zod.number().min(1).meta({
  description: "The unique identifier of the burger.",
  example: 1,
  readOnly: true,
});
```

This way, we can use the same schema for both creating and retrieving burgers.

### More advanced schemas

Let's define a more complex schema for orders, which includes an array of burger IDs, timestamps, and status fields.

```typescript filename="index.ts"
const OrderIdSchema = zod.number().min(1).meta({
  description: "The unique identifier of the order.",
  example: 1,
  readOnly: true,
});

const orderStatusEnum = zod.enum([
  "pending",
  "in_progress",
  "ready",
  "delivered",
]);

const orderSchema = zod
  .object({
    id: OrderIdSchema,
    burger_ids: zod
      .array(BurgerIdSchema)
      .nonempty()
      .meta({
        description: "The burgers in the order.",
        example: [1, 2],
      }),
    time: zod.iso.datetime().meta({
      description: "The time the order was placed.",
      example: "2021-01-01T00:00:00.000Z",
    }),
    table: zod.number().min(1).meta({
      description: "The table the order is for.",
      example: 1,
    }),
    status: orderStatusEnum.meta({
      description: "The status of the order.",
      example: "pending",
    }),
    note: zod.string().optional().meta({
      description: "A note for the order.",
      example: "No onions.",
    }),
  })
  .meta({
    description: "An order placed at the restaurant.",
  });
```

### Defining operations

Operations need to be defined before they can be registered in the OpenAPI document. Define an operation for creating and getting burgers and orders, and listing burgers:

```typescript filename="index.ts"

const createBurger: ZodOpenApiOperationObject = {
  operationId: "createBurger",
  summary: "Create a new burger",
  description: "Creates a new burger in the database.",
  tags: ["burgers"],
  requestBody: {
    description: "The burger to create.",
    content: {
      "application/json": {
        schema: burgerSchema,
      },
    },
  },
  responses: {
    "201": {
      description: "The burger was created successfully.",
      content: {
        "application/json": {
          schema: burgerSchema,
        },
      },
    },
  },
};

const getBurger: ZodOpenApiOperationObject = {
  operationId: "getBurger",
  summary: "Get a burger",
  description: "Gets a burger from the database.",
  tags: ["burgers"],
  requestParams: {
    path: zod.object({ id: BurgerIdSchema }),
  },
  responses: {
    "200": {
      description: "The burger was retrieved successfully.",
      content: {
        "application/json": {
          schema: burgerSchema,
        },
      },
    },
  },
};

const listBurgers: ZodOpenApiOperationObject = {
  operationId: "listBurgers",
  summary: "List burgers",
  description: "Lists all burgers in the database.",
  tags: ["burgers"],
  responses: {
    "200": {
      description: "The burgers were retrieved successfully.",
      content: {
        "application/json": {
          schema: zod.array(burgerSchema),
        },
      },
    },
  },
};

// Order operations
const createOrder: ZodOpenApiOperationObject = {
  operationId: "createOrder",
  summary: "Create a new order",
  description: "Creates a new order in the database.",
  tags: ["orders"],
  requestBody: {
    description: "The order to create.",
    content: {
      "application/json": {
        schema: orderSchema,
      },
    },
  },
  responses: {
    "201": {
      description: "The order was created successfully.",
      content: {
        "application/json": {
          schema: orderSchema,
        },
      },
    },
  },
};

const getOrder: ZodOpenApiOperationObject = {
  operationId: "getOrder",
  summary: "Get an order",
  description: "Gets an order from the database.",
  tags: ["orders"],
  requestParams: {
    path: zod.object({ id: OrderIdSchema }),
  },
  responses: {
    "200": {
      description: "The order was retrieved successfully.",
      content: {
        "application/json": {
          schema: orderSchema,
        },
      },
    },
  },
};
```

### Adding a webhook that runs when a burger is created

Webhooks are like operations that runs when a server-side action is triggered, e.g. when a burger has been created. They're similar enough that zod-openapi uses the same `ZodOpenApiOperationObject` type to define the webhook.

```typescript filename="index.ts"
const createBurgerWebhook: ZodOpenApiOperationObject = {
  operationId: "createBurgerWebhook",
  summary: "New burger webhook",
  description: "A webhook that is called when a new burger is created.",
  tags: ["burgers"],
  requestBody: {
    description: "The burger that was created.",
    content: {
      "application/json": {
        schema: burgerSchema,
      },
    },
  },
  responses: {
    "200": {
      description: "The webhook was processed successfully.",
    },
  },
};
```

### Registering all paths, webhooks, and extensions

Now, register all schemas, paths, webhooks, and the `x-speakeasy-retries` extension:

```typescript filename="index.ts"
const document = createDocument({
  openapi: "3.1.0",
  info: {
    title: "Burger Restaurant API",
    description: "An API for managing burgers and orders at a restaurant.",
    version: "1.0.0",
  },
  servers: [
    {
      url: "https://example.com",
      description: "The production server.",
    },
  ],
  paths: {
    "/burgers": {
      post: createBurger,
      get: listBurgers,
    },
    "/burgers/{id}": {
      get: getBurger,
    },
    "/orders": {
      post: createOrder,
    },
    "/orders/{id}": {
      get: getOrder,
    },
  },
  webhooks: {
    "/burgers": {
      post: createBurgerWebhook,
    },
  },
  components: {
    schemas: {
      burgerSchema,
      BurgerIdSchema,
      orderSchema,
      OrderIdSchema,
    },
  },
  // Adding Speakeasy extensions for better SDK generation
  "x-speakeasy-retries": {
    strategy: "backoff",
    backoff: {
      initialInterval: 500,
      maxInterval: 60000,
      maxElapsedTime: 3600000,
      exponent: 1.5,
    },
    statusCodes: ["5XX"],
    retryConnectionErrors: true,
  },
});

console.log(yaml.stringify(document));
```

Speakeasy will read the `x-speakeasy-*` extensions to configure the SDK. In this example, the `x-speakeasy-retries` extension will configure the SDK to retry failed requests. For more information on the available extensions, see the [extensions guide](/openapi/extensions).

### Generating the OpenAPI document

Run the `index.ts` file to generate the OpenAPI document.

```bash filename="Terminal"
npx tsx index.ts > openapi.yaml
```

The output will be a YAML file that looks like this:

```yaml
openapi: 3.1.0
info:
  title: Burger Restaurant API
  description: An API for managing burgers and orders at a restaurant.
  version: 1.0.0
servers:
  - url: https://example.com
    description: The production server.
x-speakeasy-retries:
  strategy: backoff
  backoff:
    initialInterval: 500
    maxInterval: 60000
    maxElapsedTime: 3600000
    exponent: 1.5
  statusCodes:
    - 5XX
  retryConnectionErrors: true
paths:
  /burgers:
    post:
      operationId: createBurger
      summary: Create a new burger
      description: Creates a new burger in the database.
      tags:
        - burgers
      requestBody:
        description: The burger to create.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/burgerSchema"
      responses:
        "201":
          description: The burger was created successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/burgerSchemaOutput"
    get:
      operationId: listBurgers
      summary: List burgers
      description: Lists all burgers in the database.
      tags:
        - burgers
      responses:
        "200":
          description: The burgers were retrieved successfully.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/burgerSchemaOutput"
  /burgers/{id}:
    get:
      operationId: getBurger
      summary: Get a burger
      description: Gets a burger from the database.
      tags:
        - burgers
      parameters:
        - in: path
          name: id
          schema:
            $ref: "#/components/schemas/BurgerIdSchema"
          required: true
          description: The unique identifier of the burger.
      responses:
        "200":
          description: The burger was retrieved successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/burgerSchemaOutput"
  /orders:
    post:
      operationId: createOrder
      summary: Create a new order
      description: Creates a new order in the database.
      tags:
        - orders
      requestBody:
        description: The order to create.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/orderSchema"
      responses:
        "201":
          description: The order was created successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/orderSchemaOutput"
  /orders/{id}:
    get:
      operationId: getOrder
      summary: Get an order
      description: Gets an order from the database.
      tags:
        - orders
      parameters:
        - in: path
          name: id
          schema:
            $ref: "#/components/schemas/OrderIdSchema"
          required: true
          description: The unique identifier of the order.
      responses:
        "200":
          description: The order was retrieved successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/orderSchemaOutput"
webhooks:
  /burgers:
    post:
      operationId: createBurgerWebhook
      summary: New burger webhook
      description: A webhook that is called when a new burger is created.
      tags:
        - burgers
      requestBody:
        description: The burger that was created.
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/burgerSchema"
      responses:
        "200":
          description: The webhook was processed successfully.
components:
  schemas:
    burgerSchema:
      description: A burger served at the restaurant.
      type: object
      properties:
        id:
          $ref: "#/components/schemas/BurgerIdSchema"
        name:
          description: The name of the burger.
          example: Veggie Burger
          type: string
          minLength: 1
          maxLength: 50
        description:
          description: The description of the burger.
          example: A delicious bean burger with avocado.
          type: string
          maxLength: 255
      required:
        - id
        - name
    BurgerIdSchema:
      description: The unique identifier of the burger.
      example: 1
      readOnly: true
      type: number
      minimum: 1
    orderSchema:
      description: An order placed at the restaurant.
      type: object
      properties:
        id:
          $ref: "#/components/schemas/OrderIdSchema"
        burger_ids:
          description: The burgers in the order.
          example:
            - 1
            - 2
          minItems: 1
          type: array
          items:
            $ref: "#/components/schemas/BurgerIdSchema"
        time:
          description: The time the order was placed.
          example: 2021-01-01T00:00:00.000Z
          type: string
          format: date-time
          pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
        table:
          description: The table the order is for.
          example: 1
          type: number
          minimum: 1
        status:
          description: The status of the order.
          example: pending
          type: string
          enum:
            - pending
            - in_progress
            - ready
            - delivered
        note:
          description: A note for the order.
          example: No onions.
          type: string
      required:
        - id
        - burger_ids
        - time
        - table
        - status
    OrderIdSchema:
      description: The unique identifier of the order.
      example: 1
      readOnly: true
      type: number
      minimum: 1
    burgerSchemaOutput:
      description: A burger served at the restaurant.
      type: object
      properties:
        id:
          $ref: "#/components/schemas/BurgerIdSchema"
        name:
          description: The name of the burger.
          example: Veggie Burger
          type: string
          minLength: 1
          maxLength: 50
        description:
          description: The description of the burger.
          example: A delicious bean burger with avocado.
          type: string
          maxLength: 255
      required:
        - id
        - name
      additionalProperties: false
    orderSchemaOutput:
      description: An order placed at the restaurant.
      type: object
      properties:
        id:
          $ref: "#/components/schemas/OrderIdSchema"
        burger_ids:
          description: The burgers in the order.
          example:
            - 1
            - 2
          minItems: 1
          type: array
          items:
            $ref: "#/components/schemas/BurgerIdSchema"
        time:
          description: The time the order was placed.
          example: 2021-01-01T00:00:00.000Z
          type: string
          format: date-time
          pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
        table:
          description: The table the order is for.
          example: 1
          type: number
          minimum: 1
        status:
          description: The status of the order.
          example: pending
          type: string
          enum:
            - pending
            - in_progress
            - ready
            - delivered
        note:
          description: A note for the order.
          example: No onions.
          type: string
      required:
        - id
        - burger_ids
        - time
        - table
        - status
      additionalProperties: false
```

### Generating an SDK

With our OpenAPI document complete, we can now generate an SDK using the Speakeasy SDK generator.

#### Installing the Speakeasy CLI

First, install the Speakeasy CLI:

```bash filename="Terminal"
# Option 1: Using Homebrew (recommended)
brew install speakeasy-api/tap/speakeasy

# Option 2: Using curl
curl -fsSL https://go.speakeasy.com/cli-install.sh | sh
```

#### Linting OpenAPI documents

Before generating SDKs, lint the OpenAPI document to catch common issues:

```bash filename="Terminal"
speakeasy lint openapi --schema openapi.yaml
```

#### Generating your SDK

Now generate your SDK using the quickstart command:

```bash filename="Terminal"
speakeasy quickstart
```

Follow the onscreen prompts to provide the necessary configuration details for your new SDK, such as the name, schema location, and output path. Enter `openapi.yaml` when prompted for the OpenAPI document location, and select preferred language when prompted.

## Using your generated SDK

Once the SDK is generated, [publish](/docs/publish-sdk) it for use. For TypeScript, it can be published as an NPM package.

TypeScript SDKs generated with Speakeasy include an installable [Model Context Protocol (MCP) server](/docs/standalone-mcp/build-server) where the various SDK methods are exposed as tools that AI applications can invoke. The SDK documentation includes instructions for installing the MCP server.

<Callout title="Production Readiness" type="warning">
  Note that the SDK is not ready for production use immediately after
  generation. To get it production-ready, follow the steps outlined in the
  Speakeasy workspace.
</Callout>

### Adding SDK generation to your CI/CD pipeline

The Speakeasy [`sdk-generation-action`](https://github.com/speakeasy-api/sdk-generation-action) repository provides workflows for integrating the Speakeasy CLI into CI/CD pipelines to automatically regenerate SDKs when your Zod schemas change.

Speakeasy can be set up to automatically push a new branch to SDK repositories so that teammates can review and merge the SDK changes.

For an overview of how to set up SDK automation, see the Speakeasy [SDK workflow syntax reference](/docs/speakeasy-reference/workflow-file).

## Summary

In this tutorial, we learned how to generate OpenAPI schemas from Zod and create client SDKs with Speakeasy.

By following these steps, it's possible to ensure an API is well-documented, easy to use, and offers a great developer experience.

### Further reading

- [The `zod-openapi` documentation](https://github.com/samchungy/zod-openapi): Learn more about the `zod-openapi` library, including advanced features like custom serializers and middleware integration.
- [The Zod documentation](https://zod.dev/): Comprehensive guide to Zod schema validation, including the latest v4 features.
