Skip to content

AdvancedEncoding

Encoding

Control how request bodies and query parameters are serialized.

4 min readboth modes

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:

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

VariablesBody sent
An array, a string, a number or a booleanJSON, as it is: [1,2,3], "text", 42
FormDataMultipart, with the browser's boundary
Blob or FileIts bytes, with its own type as the content type
ArrayBuffer, Uint8Array or any typed arrayIts bytes
ReadableStreamStreamed as it is read
URLSearchParamsapplication/x-www-form-urlencoded
ts
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:

api/index.ts
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):

api/index.ts
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:

api/index.ts
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:

api/index.ts
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:

api/index.ts
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:

typescript
// 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=123

Next Steps