Overview
Every API in Courier is described by three building blocks:
courier— returns the heads endpoints are built from, carrying whatever settings they sharedefineGroup— organizes related endpoints under a common URL prefixdefineApi— 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:
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.
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,optionsandqueryread, and are proxied as aGETa CDN can cache;post,put,patchanddelwrite, and are proxied as aPOST.stream— server-sent events.channel— a WebSocket channel.
Streams and channels are covered in Streams & Channels.
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():
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:
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
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 readThe 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:
endpoint
.variables<{ id: number; name: string }>()
.via((http, { name }) => http.post(":id/rename", undefined, { searchParams: { name } }))
// → POST /pets/5/rename?name=rex (no body)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:
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:
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:
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=proA 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:
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=taxA 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.
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%2CtotalresponseType 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:
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:
endpoint.get(":id/posts/:postId").variables<{ id: string; postId: string }>().response<UserPost>()
// caller.users.getUserPost({ id: "1", postId: "42" })
// → GET /users/1/posts/42A 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.
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:
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 |
.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 |
.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 |
A verb also takes options for that endpoint alone:
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 withoutmutations: true, and the mock server answers a repeated key with its first answer. A key the caller sends is kept. Anything butheader:<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.inherit: false— skip the base's middleware; the endpoint's own.use()and the source'smiddlewarestill 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:
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 sourcesEndpoints 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:
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.
Options
defineApi accepts all BaseOptions:
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"
deduplicate: true, // automatic request deduplication
defaultBody: { lang: "en" },
defaultQueryParams: { format: "json" },
})Multiple Sources
You can define multiple API sources for different backends:
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 sourcesThen call them by name:
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:
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 boundaries.
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 or createProxyQueryCaller given source or boundary:
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 hooksOne 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 given only a source name cannot tell a declared member from a runtime hook, and answers these names as absent.
Next Steps
- Caller & Proxy — how to call your endpoints
- Middleware — add retry, auth, logging to your API
- Validation — runtime schema validation