Default behavior
By default, Courier serializes variables based on the HTTP method. GET, HEAD, OPTIONS and DELETE methods send variables as URL search params. POST, PUT, PATCH and QUERY send them as a JSON body:
import { courier, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
type User = { id: number; name: string; email: string }
const users = defineGroup({
prefix: "users",
endpoints: {
// GET, DELETE -> variables go to URLSearchParams
// GET /users?page=1&limit=10
getAll: endpoint.get("").variables<{ page: number; limit: number }>().response<User[]>(),
// POST, PUT, PATCH -> variables go to JSON body
// POST /users Body: { "name": "Jane", "email": "jane@test.com" }
create: endpoint.post("").variables<{ name: string; email: string }>().response<User>(),
// HEAD -> like GET, variables go to URLSearchParams; no body
check: endpoint.head("").response<void>(),
// QUERY -> a read whose variables go to the JSON body
// QUERY /users/search Body: { "team": "core", "roles": ["admin"] }
search: endpoint.query("search").variables<{ team: string; roles: string[] }>().response<User[]>(),
},
})
await caller.users.getAll({ page: 1, limit: 10 })
await caller.users.create({ name: "Jane", email: "jane@test.com" })Variables that are not an object
An object's entries fill the path parameters and become the query string or the body. Variables of any other shape are sent whole as the body of a POST, PUT or PATCH:
| Variables | Body sent |
|---|---|
| An array, a string, a number or a boolean | JSON, as it is: [1,2,3], "text", 42 |
FormData | Multipart, with the browser's boundary |
Blob or File | Its bytes, with its own type as the content type |
ArrayBuffer, Uint8Array or any typed array | Its bytes |
ReadableStream | Streamed as it is read |
URLSearchParams | application/x-www-form-urlencoded |
const bulk = endpoint.post("users/bulk").variables<{ name: string }[]>()
// POST /users/bulk Body: [{"name":"Jane"},{"name":"John"}]
await caller.users.bulk([{ name: "Jane" }, { name: "John" }])A GET, HEAD, OPTIONS or DELETE puts its variables in the query string, so they must be an object there; anything else is refused with an HTTPError 400 before a request is made. defaultBody and bodySerializer apply to an object's fields and leave a body of any other shape alone.
Raw bodies and the proxy
The proxy carries variables as JSON or FormData. A Blob, bytes, a stream or URLSearchParams passed to a proxy caller is refused with ConfigurationError("PROXY_BODY_NOT_SUPPORTED") before anything is sent. Upload a file in a FormData instead, and have the endpoint's closure build the upstream body on the server when it needs raw bytes.
bodySerializer
Override the default JSON serialization for request bodies. This is useful when your backend expects a different format like application/x-www-form-urlencoded, Protocol Buffers, or any custom encoding:
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
const { endpoint } = courier()
const auth = defineGroup({
prefix: "auth",
endpoints: {
login: endpoint.post("token").variables<{
grant_type: string
username: string
password: string
}>().response<{ token: string }>(),
},
})
const api = defineApi({
baseURL: process.env.API_URL,
groups: { auth },
// Custom body serialization — e.g., x-www-form-urlencoded
bodySerializer: (data) => {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(data)) {
params.append(key, String(value))
}
return params
},
})
// POST /auth/token
// Content-Type: application/x-www-form-urlencoded
// Body: grant_type=password&username=jane&password=secret
await caller.auth.login({
grant_type: "password",
username: "jane",
password: "secret",
})Scope
bodySerializer is set at the source level in defineApi() and applies to all POST/PUT/PATCH requests for that source. If you need different encoding per group, create separate sources.
searchParamsSerializer
Unified serializer for query parameters. Handles both encoding (object to string) and parsing (string to object). The default behavior converts each key-value pair to a simple key=value string using URLSearchParams.
You can pass any library with stringify and parse methods (e.g., qs):
import { courier, defineApi, defineGroup } from "@noverax/courier/define"
import qs from "qs"
const { endpoint } = courier()
type Product = { id: number; name: string; category: string }
const products = defineGroup({
prefix: "products",
endpoints: {
search: endpoint.get("").variables<{
category: string
tags: string[]
}>().response<Product[]>(),
},
})
const api = defineApi({
baseURL: process.env.API_URL,
groups: { products },
// Use qs for nested objects and arrays
searchParamsSerializer: qs,
})
// GET /products?category=electronics&tags[]=sale&tags[]=new
await caller.products.search({
category: "electronics",
tags: ["sale", "new"],
})Or provide custom stringify and parse functions:
const api = defineApi({
baseURL: process.env.API_URL,
groups: { products },
searchParamsSerializer: {
stringify: (data) => {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(data)) {
if (Array.isArray(value)) {
for (const item of value) params.append(`${key}[]`, String(item))
} else {
params.append(key, String(value))
}
}
return params.toString()
},
parse: (str) => Object.fromEntries(new URLSearchParams(str).entries()),
},
})defaultBody
Provide default values that are merged into every request. For GET, HEAD, OPTIONS and DELETE, they become query params. For POST/PUT/PATCH/QUERY, they are merged into the JSON body. Caller variables override defaults with the same key:
const api = defineApi({
baseURL: process.env.API_URL,
groups: { users, posts },
// Merged into every request body (POST/PUT/PATCH/QUERY)
// and every query string (GET/DELETE)
defaultBody: {
apiVersion: "v2",
locale: "en",
},
})
// POST /users Body: { "apiVersion": "v2", "locale": "en", "name": "Jane" }
await caller.users.create({ name: "Jane" })
// GET /users?apiVersion=v2&locale=en&page=1
await caller.users.getAll({ page: 1 })defaultQueryParams
Unlike defaultBody, which goes to the body for POST requests, defaultQueryParams always goes to the URL query string regardless of HTTP method:
const api = defineApi({
baseURL: process.env.API_URL,
groups: { users, posts },
// Merged into query params for ALL methods (including POST)
// Unlike defaultBody, this only affects the URL query string
defaultQueryParams: {
api_key: "public-key-123",
},
})
// GET /users?api_key=public-key-123&page=1
await caller.users.getAll({ page: 1 })
// POST /users?api_key=public-key-123 Body: { "name": "Jane" }
await caller.users.create({ name: "Jane" })When to use which
Use defaultBody for values that should be part of the request payload (body or query depending on method). Use defaultQueryParams for values that must always appear in the URL, like API keys or version identifiers.
Complete variable flow
Understanding the order of operations helps when combining path params, defaults, and caller variables:
// Complete variable flow:
// 1. Path params are extracted first (see Path Parameters page)
// /users/:id + { id: 5, name: "Jane" } -> URL: /users/5, remaining: { name: "Jane" }
// 2. defaultBody is merged with remaining variables
// defaultBody: { locale: "en" }
// merged: { locale: "en", name: "Jane" }
// 3. defaultQueryParams is merged into search params
// defaultQueryParams: { api_key: "123" }
// 4. Method determines where merged variables go:
// GET/DELETE -> URLSearchParams: /users/5?locale=en&name=Jane&api_key=123
// POST/PUT/QUERY -> JSON body: { locale: "en", name: "Jane" } + query: ?api_key=123Next Steps
- Path Parameters — dynamic URL segments extracted from variables
- File Upload — FormData-based uploads with progress
- Options Reference — timeout, retry, caching, and more