# Defining APIs

Define your API structure with courier, defineGroup, and defineApi — the foundation of type-safe API calls.

## Overview

Every API in Courier is described by three building blocks:

- **`courier`** — returns the heads endpoints are built from, carrying whatever settings they share
- **`defineGroup`** — organizes related endpoints under a common URL prefix
- **`defineApi`** — creates an API source with a base URL, groups, and shared options (middleware, headers, etc.)

All three are imported from `@noverax/courier/define`.

## defineGroup

Groups related endpoints under a URL prefix:

```ts title="api/users.ts"
import { courier, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: string; name: string; email: string }

export const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").response<User[]>(),
    getById: endpoint.get(":id").variables<{ id: string }>().response<User>(),
    create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
    update: endpoint.put(":id").variables<{ id: string; name: string }>().response<User>(),
    remove: endpoint.del(":id").variables<{ id: string }>().response<null>(),
  },
})
```

A group may hold groups of its own, under `groups`, to any depth: `caller.billing.invoices.list()` reaches `list` in the `invoices` group inside `billing`, at both groups' prefixes in turn. See [Groups inside groups](/docs/api/define-group#groups-inside-groups).

## courier

`courier()` returns three heads and `extend`. Which head you start from declares what the endpoint is, not just what it returns:

- **`endpoint`** — an HTTP endpoint. The verb that addresses it decides whether it reads or writes: `get`, `head`, `options` and `query` read, and are proxied as a `GET` a CDN can cache; `post`, `put`, `patch` and `del` write, and are proxied as a `POST`.
- **`stream`** — server-sent events.
- **`channel`** — a WebSocket channel.

Streams and channels are covered in [Streams & Channels](/docs/advanced/realtime).

From a head you chain the steps that shape the endpoint: a verb with its path — or `.via()` with a closure that builds the request — and the slots `.variables()` and `.response()`:

```ts
const { endpoint } = courier()

// Explicit types — for an API that publishes no usable schema
endpoint.get(":id").variables<{ id: string }>().response<User>()

// Response only — the caller passes nothing
endpoint.get("").response<User[]>()

// With schema — types inferred from Zod/Valibot/ArkType, and validated at runtime
endpoint.post("").variables(CreateUserSchema).response(UserSchema)
```

`.via()` receives `http` — an object with typed HTTP methods (`get`, `post`, `put`, `patch`, `del`, `head`, `options`, `query`) — and optionally `vars` (the variables).

A base can carry what a whole vendor surface shares, so no endpoint repeats it:

```ts
const vendor = courier({ timeout: 10_000, middleware: [bearer] })
const { endpoint } = vendor

// Both inherit the timeout and the middleware
endpoint.get("").response<User[]>()
endpoint.post("").response<User>()
```

### HTTP Methods

```ts
http.get()                    // GET /users
http.get(":id", vars)         // GET /users/5 (path param from vars)
http.post("", vars)           // POST /users with JSON body
http.put(":id", vars)         // PUT /users/5 with JSON body
http.patch(":id", vars)       // PATCH /users/5 with JSON body
http.del(":id", vars)         // DELETE /users/5
http.options(":id", vars)     // OPTIONS /users/5 — what the resource allows
http.query("search", vars)    // QUERY /users/search with JSON body — a read
```

The second argument is what the call sends: the body of a write or a QUERY, or the query string of any other read. Left out, the endpoint's own variables are sent; passed as `undefined`, nothing is, so a write can put a value in the query string alone. The path is filled from the endpoint's variables either way, and a key of the second argument that names one of the path's parameters is taken out of what is sent, as it is from the variables — a value there neither changes the path nor reaches the body. To send a path parameter in the body too, give the whole body in the third argument's `json`, which is sent as it is — the variables then only fill the path, as the DELETE below shows:

```ts
endpoint
  .variables<{ id: number; name: string }>()
  .via((http, { name }) => http.post(":id/rename", undefined, { searchParams: { name } }))
// → POST /pets/5/rename?name=rex  (no body)

endpoint
  .variables<{ id: number; name: string }>()
  .via((http, v) => http.put(":id", { id: v.id, name: v.name }))
// → PUT /pets/5  Body: {"name":"rex"} — :id takes the id, and the body leaves it out
```

The third argument takes per-call options. A `body` or `json` named there is sent as the request body, and the variables then only fill the path; its `headers` are sent as written, whatever their name. This is also how a DELETE carries a body, for an API that requires one:

```ts
endpoint
  .variables<{ id: number; message: string; sha: string }>()
  .via((http, { id, ...reason }) => http.del(":id", { id }, { json: reason }))
// → DELETE /files/7  Body: {"message":"…","sha":"…"}

endpoint
  .variables<{ file: Blob }>()
  .via((http, v) => http.put("raw", undefined, { body: v.file, headers: { "content-type": "image/png" } }))
```

### Asking with a body: QUERY

HTTP QUERY is a read whose question travels in the body: a filter too long for an address, or too structured to flatten into one — nested conditions, a list of ids, a query language. `endpoint.query(path)` addresses one. It reads as `get` does — it is retried, its answer may be kept with `.cache()`, the query hooks take it, and the proxy carries it as any read: as a `GET`, or as a `POST` when its variables would not fit in an address — and it sends its variables as `post` does, its path filled from them and the rest as a JSON body:

```ts
const Filter = z.object({ team: z.string(), status: z.array(z.enum(["active", "invited"])) })

const people = defineGroup({
  prefix: "people",
  endpoints: {
    search: endpoint.query("search").variables(Filter).response(z.array(Person)),
  },
})
// → QUERY /people/search  Body: {"team":"core","status":["active"]}
```

QUERY needs an upstream that takes it: Node 22 and later parse it, and so do the mock servers Studio runs. An API that answers its searches over POST instead stays a `post` declared `.reads()`.

An API that takes its writes url-encoded, as Stripe and Twilio do, gets a body `formBody` writes from the fields. Each field is written as OpenAPI's `encoding` would name it — by default an array repeats its field and an object's properties are fields of their own; `deepObject` puts an object's properties, and its arrays' items, in brackets:

```ts
import { courier, formBody } from "@noverax/courier/define"

endpoint
  .variables<{ email: string; metadata?: Record<string, string> }>()
  .via((http, fields) =>
    http.post("customers", undefined, {
      body: formBody(fields, { metadata: { style: "deepObject", explode: true } }),
    }),
  )
// → POST /customers  Body: email=ada%40example.com&metadata%5Bplan%5D=pro
```

A read writes its variables into the query in OpenAPI's default style, `form` exploded: a list repeats its name and an object's properties become names of their own. A parameter declared in another style — Stripe's `deepObject` filters, a comma- or pipe-joined list — is written by `formQuery`, which takes the same encodings as `formBody` and leaves out a field that is `undefined` or `null`:

```ts
import { courier, formQuery } from "@noverax/courier/define"

endpoint
  .variables<{ created?: { gte?: number }; expand?: string[] }>()
  .via((http, query) =>
    http.get("customers", formQuery(query, {
      created: { style: "deepObject", explode: true },
      expand: { style: "deepObject", explode: true },
    })),
  )
// → GET /customers?created%5Bgte%5D=1680000000&expand%5B0%5D=tax
```

A stream of events is opened the same way: its closure takes the stream's own `get`, and the query is written as `formQuery` writes it.

```ts
import { courier, formQuery } from "@noverax/courier/define"

const { stream } = courier()

stream
  .variables<{ fields?: string[] }>()
  .via((sse, query) => sse.get("events", formQuery(query, { fields: { style: "form", explode: false } })))
  .response<{ status: string }>()
// → GET /events?fields=status%2Ctotal
```

`responseType` reads one endpoint's answer as `"text"`, `"blob"`, `"arrayBuffer"` or `"formData"` when its source reads the rest as JSON — a CSV export, a PDF. An error answer is still read as the source reads it, since it explains the error in its own form:

```ts
endpoint
  .variables<{ month: string }>()
  .via((http, vars) => http.get("reports/:month/export", vars, { responseType: "text" }))
  .response<string>()
```

### Path Parameters

The `:param` syntax extracts values from variables automatically:

```ts
endpoint.get(":id/posts/:postId").variables<{ id: string; postId: string }>().response<UserPost>()

// caller.users.getUserPost({ id: "1", postId: "42" })
// → GET /users/1/posts/42
```

A parameter can also come from `pathOptions` on the source or the group, so the variables type does not have to list it. One that neither provides fails the call before any request is made, with an `HTTPError` 400 whose message names it: `Missing required path parameter "postId": pass it in the variables, or return it from pathOptions`. See [Path Parameters](/docs/advanced/path-params).

### Default variables

Some APIs ask for a variable that is nearly always the same — a page size, a grouping. `.defaults()` names what the endpoint sends when a call leaves a variable out, and the caller may then leave it out:

```ts
const { endpoint } = courier()

const news = defineGroup({
  prefix: "news",
  endpoints: {
    byGroup: endpoint
      .get("")
      .variables<{ group: string; groupBy: "year" | "month"; page?: number }>()
      .defaults({ groupBy: "year" }),
  },
})

// caller.news.byGroup({ group: "press" })                   → GET /news?group=press&groupBy=year
// caller.news.byGroup({ group: "press", groupBy: "month" }) → GET /news?group=press&groupBy=month
```

The call's own value wins, and a variable sent as `undefined` counts as left out. The defaults are filled in where the call is made — on the server for a browser's call through the proxy, which therefore sends less — and before the variables are validated, so a schema that requires `groupBy` passes. They are copied into each call, the top-level variables only, and variables that are no object, a form say, are left as they are. A default names a variable the endpoint has, as data a request can carry; anything else is refused where the endpoint is defined, with `ConfigurationError("DEFAULTS_INVALID")`. A second `.defaults()` adds to the first.

Studio's Playground starts the endpoint with its defaults set and lists each as the default it is, the OpenAPI document gives each its `default` and no longer requires it, and an agent's tool shows it the same way.

### One call per slot

`.variables()`, `.response()` and `.send()` each fill a slot, and a slot takes one schema or type: a second call would replace the first. It type-checks, but throws `ConfigurationError("SLOT_ALREADY_SET")` as soon as the module defining the endpoint loads, naming the slot and the endpoint:

```ts
endpoint.get(":id").response(UserSchema).response(AdminSchema)
// ConfigurationError: [courier] .response() was called twice on GET ":id". Each slot
// takes one schema or type, and the second would replace the first: remove one of the two calls.
```

### Endpoint settings

The same settings follow any head, in any order, and a base built with `courier()` or `.extend()` passes its own on to every endpoint made from it:

| method | what it sets |
| --- | --- |
| `.use(...middleware)` | middleware for this endpoint, run after the base's |
| `.headers(names)` | the headers it carries across the proxy, each way — see [Headers](/docs/advanced/options#headers) |
| `.timeout(ms)` | how long the upstream call may take |
| `.retry({ limit, delay, backoff, retryOn, mutations })` | its retry policy, in `retryMiddleware`'s terms; a write is retried when it declares an `idempotencyKey`, or with `mutations: true` |
| `.meta(values)` | values for your own tooling; the runtime ignores them |
| `.cache({ revalidate, tags })` | how long a read's answer keeps — offered on a read alone |
| `.reads()` | a read sent with a body, over POST; refused on `.put()`, `.patch()` and `.del()`. A write declared one loses what guards a write — see [User Sessions](/docs/server-side/session#writes-another-site-sets-off) |
| `.defaults(values)` | what an HTTP endpoint sends for variables a call leaves out — see [Default variables](#default-variables) |
| `.agent({ description, access })` | offers the endpoint to AI agents as a tool of your MCP server — named on an HTTP endpoint after its verb, and not passed on by a base; see [MCP Server](/docs/agents/overview) |

A verb also takes options for that endpoint alone:

```ts
const { endpoint } = courier()

endpoint.post("orders", { idempotencyKey: "header:idempotency-key", tags: ["orders"] })
endpoint.get("customers", { sensitiveVariables: true })
endpoint.get("legacy", { deprecated: "Use /v2/legacy", inherit: false })
```

- `idempotencyKey` — the header each call sends a key of its own in, the same on every attempt a retry makes, so an upstream that deduplicates by it applies a write once: a retry policy retries such a write without `mutations: true`, and the mock server answers a repeated key with its first answer. A key the caller sends is kept. Anything but `header:<name>` is refused where the endpoint is defined.
- `sensitiveVariables` — a read whose variables stay out of the address: it goes to the proxy as POST — see [GET or POST](/docs/api/create-proxy-caller#get-or-post).
- `inherit: false` — skip the base's middleware; the endpoint's own `.use()` and the source's `middleware` still run.
- `tags`, `deprecated`, `operation` — read by Studio and the mock server, not by the runtime.
- `mock` — the example answer, status and delay the mock server gives it.

## defineApi

Creates an API source by combining groups with a base URL and shared options:

```ts title="api/sources.ts"
import { defineApi } from "@noverax/courier/define"
import { users } from "./users"
import { posts } from "./posts"

const api = defineApi({
  baseURL: process.env.API_URL!,
  groups: { users, posts },
})

export const sources = { api }
export type Sources = typeof sources
```

### Endpoints in no group

An API holds endpoints of its own beside its groups — a health check, its root, anything no group is about — and the client calls them on itself:

```ts title="api/sources.ts"
import { createCaller } from "@noverax/courier"
import { courier, defineApi } from "@noverax/courier/define"
import { users } from "./users"

const { endpoint } = courier()

const api = defineApi({
  baseURL: process.env.API_URL!,
  endpoints: {
    health: endpoint.get("health"),
  },
  groups: { users },
})

const caller = createCaller(api)
await caller.health()
await caller.users.getAll()
```

An API may hold endpoints alone, with no group at all. Studio imports an operation with no tag this way, and a path no segment groups when grouping by path — see [Import an API](/docs/studio/import-api).

### Options

`defineApi` accepts all `BaseOptions`:

```ts
defineApi({
  baseURL: "https://api.example.com",
  groups: { users, posts },

  // Shared options for all endpoints in this source:
  middleware: [retryMiddleware(), authMiddleware({ getToken: () => getToken() })],
  timeout: 10000,
  headers: { "X-API-Version": "2" },
  responseType: "json",         // "json" | "text" | "blob" | "arrayBuffer" | "formData"
  cancelPrevious: ["users.search"], // a newer search cancels the one in flight
  defaultBody: { lang: "en" },
  defaultQueryParams: { format: "json" },
})
```

## Multiple Sources

You can define multiple API sources for different backends:

```ts title="api/sources.ts"
const mainApi = defineApi({
  baseURL: "https://api.example.com",
  groups: { users, posts },
})

const authApi = defineApi({
  baseURL: "https://auth.example.com",
  groups: { auth },
})

export const sources = { mainApi, authApi }
export type Sources = typeof sources
```

Then call them by name:

```ts
const mainCaller = createCaller(mainApi)
const authCaller = createCaller(authApi)

const users = await mainCaller.users.getAll()
const token = await authCaller.auth.login({ email, password })
```

## Dot-Notation Access

When calling endpoints, use dot-notation to access groups and methods:

```ts
caller.users.getAll()         // group: "users", method: "getAll"
caller.users.getById(vars)    // group: "users", method: "getById"
caller.posts.create(vars)     // group: "posts", method: "create"
caller.billing.invoices.list() // group: "billing.invoices", method: "list"
```

A group inside another is named by its path, its names joined with dots, wherever a group is named by a string: the proxy's `_g`, Studio, mocks, query keys and a page's routes.

### Reserved names

Three names cannot be given to an endpoint or a group. Each is a type error and throws `ConfigurationError("RESERVED_ENDPOINT_NAME")` when the group or the source is defined, with a message that names the clash and suggests another name:

| Name | Refused for | Why |
|------|-------------|-----|
| `options` | endpoints | It holds the group's or the source's own settings beside its endpoints. |
| `raw` | endpoints and groups | Every client answers it with `.raw`, at its root, on each group and on each flat endpoint. |
| `with` | endpoints and groups | Every client answers it with `.with()`, in the same places. |

A path of that name is fine: `getRaw: endpoint.get(":id/raw")` reaches it.

No group or endpoint name may hold a dot, which separates the names of a group's path: one throws `ConfigurationError("DOTTED_NAME")` where it is defined.

### Names the runtime reads

The runtime reads `then`, `toJSON` and `valueOf` from any object. A group or endpoint you declare under one of these names is still reached by it, as long as the client knows the source — `createCaller` and `createQueryCaller`, and `createProxyCaller` and `createProxyQueryCaller`, which their routes tell what the source holds:

```ts
caller.files.toJSON()         // the endpoint named toJSON
caller.then.list()            // a group named then; the client is still not a promise
q.then.useQuery("list")       // the same group through the React hooks
```

One name stays out of reach: a flat endpoint named `then` at the root. It would make every awaited client call it, so rename it. A proxy client whose routes name no member by one of these names answers it as absent.

## Next Steps

- [Caller & Proxy](/docs/core-concepts/caller) — how to call your endpoints
- [Middleware](/docs/middleware/overview) — add retry, auth, logging to your API
- [Validation](/docs/validation/standard-schema) — runtime schema validation
