Skip to content

Core ConceptsDefining APIs

Defining APIs

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

9 min readboth modes

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:

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.

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.

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:

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)

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.

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:

methodwhat 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:

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.
  • 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:

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:

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.

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"
  deduplicate: true,            // automatic request deduplication
  defaultBody: { lang: "en" },
  defaultQueryParams: { format: "json" },
})

Multiple Sources

You can define multiple API sources for different backends:

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 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:

NameRefused forWhy
optionsendpointsIt holds the group's or the source's own settings beside its endpoints.
rawendpoints and groupsEvery client answers it with .raw, at its root, on each group and on each flat endpoint.
withendpoints and groupsEvery 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:

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 given only a source name cannot tell a declared member from a runtime hook, and answers these names as absent.

Next Steps