---
title: "Typed API and OpenAPI"
description: "Turn the app's actions into REST routes, an OpenAPI 3.1 document and a typed client, with idempotent writes and a breaking-change check."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "app-api"
canonical: "https://mainsdk.dev/docs/interfaces/api"
updated: "2026-10-28"
---

# Typed API and OpenAPI

> Turn the app's actions into REST routes, an OpenAPI 3.1 document and a typed client, with idempotent writes and a breaking-change check.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/api · Updated: 2026-10-28

`api` gives every action an HTTP route, an OpenAPI 3.1 document and a typed client from one schema. Reach for it when a partner, a mobile app or a script calls your app over HTTP.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/app-api.md
- Category: Developer experience

Calls:

- `api.openapi(actions, opts?)`
- `api.routes(actions)`
- `api.handler(options)`
- `api.client(doc)`
- `api.diff(before, after)`
- `api.react(options)`
- `createClient({ baseUrl, auth, fetch?, retries? })`
- `page(rows, init)`
- `pageLimit(limit, max?)`
- `paginate(list, opts?)`
- `inbound(object: handle)`
- `inboundIssues(config, env, { today? })`
- `originAllowed(origin, origins)`

In main.yaml:

```yaml
api:
  base: /api
  actions: src/actions.ts
  openapi:
    serve: false
    include: ["*"]
    title: Acme API
  snapshot: api/openapi.json
  idempotency:
    require: [destructive]
pagination:
  key: PAGINATION_KEY                  # secret that signs list cursors
```

Guarantees, whatever the driver:

- The same actions always produce the same document
- A value that fails its output schema is never sent
- A breaking change in a shipped major version fails mainsdk api diff


## Drivers

Pick one in main.yaml with `api.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in core module | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-API-0010` | check | An action's input or output has no JSON Schema (a foreign schema without `toJSONSchema()`); the document and client type it as unknown |
| `MAIN-API-0011` | check | The committed snapshot at `api.snapshot` differs from what the code generates |
| `MAIN-API-0012` | check | `mainsdk api diff` found a breaking change in a major version that already shipped |
| `MAIN-API-0020` | check | CORS lets any origin call the API (`*`, `origin: true` or a bare `cors()`) |
| `MAIN-API-0001` | runtime | An action's HTTP mapping is invalid: a `GET` on a write or destructive action, a path parameter that isn't a top-level string or integer input key, a nested field on a `GET` or `DELETE`, two actions on one method and path, or a name segment that isn't an identifier (500) |
| `MAIN-API-0002` | runtime | An `Idempotency-Key` was reused with a different input (422) |
| `MAIN-API-0003` | runtime | The first request with this `Idempotency-Key` is still running (409, retryable) |
| `MAIN-API-0004` | runtime | An action returned a value that fails its `output` schema; nothing of it is sent (500) |
| `MAIN-API-0005` | runtime | A risk listed under `api.idempotency.require` was called without `Idempotency-Key` (400) |
| `MAIN-API-0006` | runtime | The major version was called after its `sunset` date (410) |
| `MAIN-API-0007` | runtime | The generated client got no answer, or an answer that isn't problem details or JSON (retryable for network failures) |
| `MAIN-PAGE-0001` | runtime | A list cursor is malformed, older than 24 hours, or made by a different query, tenant or user (400) |
| `MAIN-PAGE-0002` | runtime | `limit` is not a whole number of at least 1 (400) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Output schema checked before anything is sent | 1 | 0.3 | `app-api#output` |
| Versioned REST routes from the actions | 1 | 0.3 | `app-api#routes` |
| OpenAPI 3.1 document | 1 | 0.3 | `app-api#openapi` |
| Idempotency-Key for writes | 1 | 0.3 | `app-api#idempotency` |
| Typed client, one file, global fetch only | 1 | 0.3 | `app-api#client` |
| Paged and all-items helpers in the client | 1 | 0.3 | `app-api#client-pages` |
| Breaking-change diff against the snapshot | 1 | 0.3 | `app-api#diff` |
| One schema for actions, documents and the client | 1 | 0.3 | `app-api#one-schema` |
| Tenant and auth on every public route | 1 | 0.3 | `app-api#tenant-auth` |
| React hooks and TanStack Query options for the generated client | 2 | 0.7 | `client-react#generator` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```ts title="app/actions/orders.ts"
import { action } from "mainsdk";
import { s } from "mainsdk/schema";

export const refundOrder = action({
  name: "orders.refund",
  intent: "Refund an order the signed-in user owns",
  input: s.object({ orderId: s.string().uuid(), reason: s.enum(["damaged", "late"]) }),
  output: s.object({ refundId: s.string(), status: s.enum(["pending", "done"]) }),
  risk: "destructive",
  http: { method: "POST", path: "/orders/{orderId}/refund" },
  run: async ({ input }) => ({ refundId: `r_${input.orderId}`, status: "done" as const }),
});
```

```bash
npx mainsdk api openapi --out api/openapi.json
npx mainsdk api client --out src/acme-api.ts
```

## Configuration

```yaml title="main.yaml"
schema: 1
api:
  base: /api                    # public routes are /api/v<major>/…
  actions: src/actions.ts       # the module that exports the actions; the CLI and check load it
  openapi:
    serve: false                # true serves /.well-known/openapi.json, unauthenticated
    include: ["*"]              # action name globs in the document and the client
    title: Acme API
  snapshot: api/openapi.json    # the committed document that check and api diff compare against
  idempotency:
    require: [destructive]      # risks that must send Idempotency-Key
```

| Key | What it does |
| --- | --- |
| `base` | The path prefix for every public route |
| `actions` | The module that exports your actions, for `mainsdk api` and `mainsdk check`. `src/actions.ts` by default |
| `openapi.serve` | Serves the document at `/.well-known/openapi.json`. Off by default |
| `openapi.include` | Which actions appear in the document and the client. Others stay unlisted |
| `snapshot` | The committed document. `mainsdk check` fails when the code no longer matches it |
| `idempotency.require` | Risks whose writes must send an `Idempotency-Key` header |

## What you get

`api` is a core module with no drivers (spec `app-api`, 0.3): import it from `mainsdk/api`. Mount `api.handler({ actions, ... })` in front of your app's own routes, or pass it as the `api` option of an adapter. It answers a request for a path it owns and returns `undefined` for any other. Set `idempotency.ttl` (`24h` by default) in the handler options to change how long a key is remembered.

- Versioned routes, `<base>/v<major>/…`, from each action's `http` mapping. A bad mapping is refused at start.
- An OpenAPI 3.1 document that is the same bytes for the same actions, so a snapshot diffs cleanly.
- A one-file typed client with problem errors, retries, idempotency keys and paging helpers.
- An output check: a value that fails its `output` schema is never sent.

## Examples

### Call the app from another service

`mainsdk api client` writes one file with no imports and only the global `fetch`. Call it like any typed function:

```ts title="src/refund.ts"
import { createClient } from "./acme-api.ts";

const acme = createClient({ baseUrl: "https://acme.app", auth: { apiKey: process.env.ACME_KEY ?? "" } });
const orderId = "2c8b1c1e-7d7e-4b8e-9d0a-5d1a3a8f4a11";
const r = await acme.orders.refund({ orderId, reason: "late" }, { idempotencyKey: "refund-8812" });
```

Every non-2xx answer and every network failure throws an `ApiError` with `status`, `code`, `fix`, `retryable` and, for input errors, the `errors` paths.

### Read every page of a list

```ts title="src/open-orders.ts"
import { createClient } from "./acme-api.ts";

const acme = createClient({ baseUrl: "https://acme.app" });
for await (const order of acme.orders.list.all({ status: "open" })) {
  console.log(order.id);
}
```

### Stop breaking changes in CI

```bash
npx mainsdk api diff --base origin/main
```

`api diff` lists breaking changes against the snapshot. A breaking change in a major version that has already shipped fails with `MAIN-API-0012`. To change a shipped major, add a new one with `version: 2` and keep the old one until its `sunset` date.

### Make writes safe to retry

A write whose risk is listed under `api.idempotency.require` needs an `Idempotency-Key` header, or the call fails with `MAIN-API-0005`. The same key with a different input returns 422 (`MAIN-API-0002`). A key whose first request is still running returns 409 and is retryable (`MAIN-API-0003`). The client sends the key for you when you pass `idempotencyKey`.

### Retire an old major version

```ts
deprecated: { sunset: "2027-06-30" }   // after this date the old major answers 410
```

A call to a major after its `sunset` date fails with `MAIN-API-0006`. Until then the old major keeps working.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-API-0001` | An action's HTTP mapping is invalid, for example a `GET` on a write | Fix the mapping the error names |
| `MAIN-API-0002` | An `Idempotency-Key` was reused with a different input | Send a new key for a new request |
| `MAIN-API-0003` | The first request with this `Idempotency-Key` is still running | Retry the same request shortly |
| `MAIN-API-0004` | An action returned a value that fails its `output` schema; nothing is sent | Return a value that matches `output` |
| `MAIN-API-0005` | A risk listed under `idempotency.require` was called without `Idempotency-Key` | Send the header |
| `MAIN-API-0006` | A major version was called after its `sunset` date | Call the newer major |
| `MAIN-API-0007` | The client got no answer, or an answer that isn't problem details | Check the URL and the network |
| `MAIN-API-0010` | A schema has no JSON Schema, so the document types it as unknown | Use a schema from `mainsdk/schema` |
| `MAIN-API-0011` | The committed snapshot differs from the code | Run `mainsdk api openapi` and commit the result |
| `MAIN-API-0012` | `api diff` found a breaking change in a shipped major | Add a new major version instead |

## Swapping drivers

There are no drivers. The document, the client and the routes are generated from the same actions that [MCP and agents](https://mainsdk.dev/docs/interfaces/action) use, so nothing needs a second definition.

## Testing

The handler runs in process, so route tests need no server. Call it with a `Request` and assert on the `Response`; `createTestApp()` supplies the app state the actions use.

```ts title="app/api.test.ts"
import assert from "node:assert/strict";
import { action } from "mainsdk";
import { api } from "mainsdk/api";
import { s } from "mainsdk/schema";

const ping = action({
  name: "ping.get",
  intent: "Say pong",
  input: s.object({}),
  output: s.object({ pong: s.boolean() }),
  risk: "read",
  http: { method: "GET", path: "/ping", cache: { scope: "public", maxAge: "60s" } },
  run: () => ({ pong: true }),
});
const handler = api.handler({ actions: [ping] });
const res = await handler(new Request("http://app.test/api/v1/ping"));
assert.equal(res?.status, 200);
```

See the [Testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- `output` is also the list of fields allowed to leave the app. Keep it strict.
- A risk you list under `idempotency.require` cannot be retried into a double write.
- `openapi.serve` is off by default, so the document stays private until you turn it on.

## FAQ

**Is the OpenAPI document public?** No. `openapi.serve` is off by default, and when it is on it lists only the actions in `include`.

**Do I write the routes by hand?** No. Each action's `http` mapping, or the default `POST <base>/v<major>/actions/<name>`, becomes a route.

**Can I change a shipped version?** Not in a way that breaks clients. Add `version: 2` and keep the old major until its `sunset` date.

## Reference

`api` comes from `mainsdk/api`. The paging and inbound helpers below are in `mainsdk`.

| Call | What it does |
| --- | --- |
| `api.openapi(actions, options?)` | Returns the OpenAPI 3.1 document for the actions, the same one `mainsdk api openapi` writes. `options.include` filters by action name glob. The same actions always give the same document |
| `api.handler({ actions, base?, openapi?, idempotency?, identify? })` | Serves the public routes: checks the input, runs the action with the caller `identify` returns, checks the output, adds `Deprecation`, `Sunset` and `Link` headers, keeps `Idempotency-Key` writes safe and serves the document with an ETag when `openapi.serve` is on. It throws a `MainError` for a refusal, which your adapter turns into problem details |
| `api.client(document)` | Returns the source of the typed client for a document: one file, no imports |
| `api.diff(before, after)` | Lists the changes between two documents as `{ code: "MAIN-API-0012", severity: "breaking" \| "warning", operation, pointer, message }` |
| `api.react({ clientModule })` | Returns the source of the React bindings for a generated client: `ApiProvider`, `useAction`, `useRead`, `useList`, `useSession`, `useLive`, `queryOptions` and `mutationOptions`. One file that imports only `react` and the client. `mainsdk api client --react` writes it |
| `api.routes(actions)` | Returns the method, versioned path and action for each public route, so an adapter can mount them. A bad mapping is refused at start with `MAIN-API-0001` |
| `page(rows, init)` | Builds a page from up to `limit + 1` rows: drops the extra row and makes the opaque `next` cursor for the last row kept. `init` is `{ limit, key, query }`. `page.read(cursor, { query })` opens a cursor, `page.options(req)` reads a request's `limit` and `cursor`, `page.response(page, init)` answers with the page and a `Link: rel="next"` header, and `page.configure({ key })` seals every cursor with a key of 16 or more characters |
| `pageLimit(limit, max?)` | The page size to use: 50 by default, lowered to the maximum (100). A limit that is not a whole number of 1 or more fails with `MAIN-PAGE-0002` (400) |
| `paginate(list, opts?)` | Walks every page of a `list({ limit, cursor })` call lazily. Iterate it with `for await`, or use `.pages()` and `.all()`. `opts` is `{ limit?, max?, signal? }`. A list that returns the cursor it was given fails with `MAIN-PAGE-0001` |
| `inbound` | The inbound protection object: call `inbound.handle(req, next)` to wrap a handler with secure headers, CSRF, CORS and cookie flags. It is an object with members, so there is no `inbound()` call. |
| `inboundIssues(config, env, { today? })` | Checks the `inbound` section of `main.yaml` and returns every unknown key, bad value, open CORS rule and unsafe CSP as `{ code, key, message, fix }`. Pure; never throws |
| `originAllowed(origin, origins)` | True when an `Origin` header matches an origins list: exact, or `https://*.domain` for subdomains only. `null` and `*` never match |

## Next

- [Actions](https://mainsdk.dev/docs/interfaces/action): the single definition every door uses.
- [API keys](https://mainsdk.dev/docs/interfaces/apikeys): who may call the routes.
- [Schema](https://mainsdk.dev/docs/interfaces/schema): the input and output schemas.
- [Typed API guide](https://mainsdk.dev/docs/guides/typed-api): publish the API and client for a partner.
- [Call your API from React](https://mainsdk.dev/docs/guides/use-react-hooks): hooks for the generated client.
