API Design
How to design an HTTP API that other people can build on and that you can keep changing: resources and status codes, errors, pagination that doesn't skip rows, evolving without breaking clients, REST vs GraphQL vs gRPC, and the reliability features every serious API ends up needing.
The 30-second version
Two true-to-life stories:
- A shop's backend team renamed one JSON field,
total→total_cents, the same day the new mobile app shipped. The new app worked. But 40% of phones hadn't updated yet, and on every one of them the checkout screen showed "Total: $NaN". Nothing on those phones had changed. - An internal
GET /ordersendpoint returned all orders. Fine with 200 orders at launch. Three years later it returned 2 GB of JSON, took 40 seconds, and knocked over the server every time the finance team opened their dashboard.
Neither was a coding bug. Both were design bugs: the first broke the contract with existing clients, and the second had no Pagination built in from the start. This topic is about avoiding both kinds.
Try it. Twenty phones run different versions of the shop app. Pick what the server team ships:
{
"id": "ord_2",
"status": "paid",
"total_cents": 4850
}total, get undefined, and show $NaN. Nobody changed those phones, yet they broke.Why this is harder than it looks
When you write a function, you can change it and fix every caller in the same commit. With an API, the callers aren't yours. They're apps on phones you can't update, partner companies on their own schedules, scripts someone wrote two years ago. A change that would be a five-minute refactor inside one codebase becomes a months-long migration across a hundred.
So API design is mostly about choices that are expensive to undo: names, shapes, how errors look, how lists are paged, how you'll change things later. This topic covers them, using one running example, an online shop with orders, items and payments:
| § | You'll learn |
|---|---|
| 02–03 | How to name things (resources and URLs), and which method and status code to use |
| 04–05 | A real orders API in Go and Node, and an error format clients can act on |
| 06–07 | Pagination that doesn't skip or repeat rows, and why OFFSET gets slow (measured) |
| 08 | Changing an API without breaking anyone, and versioning when you must |
| 09 | REST vs GraphQL vs gRPC: the same screen in each, and when each one wins |
| 10–11 | Retries, rate limits, slow jobs, webhooks; and the staff-level toolkit (OpenAPI, guidelines, compatibility tests) |
Resources: naming the things in your API
From functions to resources
If you'd never seen an HTTP API, you'd probably design the shop like a list of functions: /getOrder?id=42, /createOrder, /cancelOrder. That works, but every API invents its own names, and nothing tells a client which calls are safe to retry or to cache.
REST turns it around. You pick the things your shop has, called resources, and give each one a URL. Then you use the standard HTTP methods on them. Every client already knows what GET and DELETE mean, so there's less to learn and less to get wrong.
The shop's nouns
Start by listing the nouns in your product and how they relate. For the shop:
/customers/{id}
/orders/{id}
/orders/{id}/items
/payments/{id}
created via /orders/{id}/payments
Each noun gives you two kinds of URL:
- A collection, a plural noun:
/orders.GETlists orders,POSTcreates one. - A member of the collection:
/orders/ord_42.GETreads it,PATCHchanges some of its fields,DELETEremoves it. - Things that only exist inside a member become a sub-resource under its URL:
/orders/ord_42/items.
The ord_42 part is a path parameter: routers write the pattern as /orders/{id}. Optional inputs like filters go in query parameters after the ?: /orders?status=paid.
Those four operations (create, read, update, delete) are called CRUD, and they cover most of what an API does.
Explore it
GET /orders?status=paid&limit=2
200 OK
{
"data": [
{ "id": "ord_42", "status": "paid", "total_cents": 4850 },
{ "id": "ord_40", "status": "paid", "total_cents": 1200 }
],
"next_cursor": "eyJpZCI6NDB9"
}?status=paid), not in the path.Rules of thumb
| Do | Don't | Why |
|---|---|---|
/orders, /orders/ord_42 | /getOrders, /order/42 | The method already says "get"; plural collections read naturally both as a list and with an id. |
/orders?status=paid&customer=c7 | /orders/paid/customer/c7 | Filters are optional and can be combined; the path is for identity. |
/orders/ord_42/items | /customers/c7/orders/ord_42/items/3 | One level of nesting. Deeper paths force clients to know every parent id. |
ids like "ord_42" (strings) | ids like 42 (numbers) | You can change how ids are made later (to UUIDs, say) without a type change. The prefix tells a human what kind of id it is. |
snake_case or camelCase, everywhere | a mix | Pick one per API and never deviate. Clients write code against the names. |
Actions that don't fit CRUD
Some operations aren't "change a field". Cancelling an order refunds the payment, releases the stock and emails the customer. You could model it as PATCH /orders/ord_42 {"status": "cancelled"}, but that hides real work behind a field write. It also invites nonsense like PATCH {"status": "shipped"} from a client.
Two honest options:
- An action sub-path:
POST /orders/ord_42/cancel. Clear, easy to secure, easy to document. Google's API guidelines call these custom methods and write themPOST /orders/ord_42:cancel; the idea is the same. - Turn the action into a noun:
POST /orders/ord_42/cancellationscreates a cancellation record, which you canGETlater to see who cancelled and why. Worth it when the action has its own data and history (refunds are the classic example).
Either is fine. Pick one style and use it everywhere.
Choosing methods and status codes
HTTP §02 lists the methods and the five classes of status code. Here we're choosing between them for the shop.
Methods: what each promises
| Method | Use it for | Safe? | Idempotent? |
|---|---|---|---|
GET | Read a resource or list a collection | yes | yes |
POST | Create a member of a collection; run an action | no | no |
PUT | Replace a resource completely | no | yes |
PATCH | Change some fields | no | not guaranteed |
DELETE | Remove a resource | no | yes |
Safe means "only reads". Browsers, CDNs and crawlers assume GET is safe and will happily prefetch, cache and repeat it. So never make a GET /orders/42/cancel link: a link-preview bot will cancel the order.
Idempotent means doing it twice has the same effect as once. PATCH depends on the body: PATCH {"quantity": 3} sets a value, so repeating it changes nothing more; PATCH {"add_quantity": 1} adds one every time it's sent. A client whose PUT timed out can simply send it again. A POST is different: sending it again might create a second order. §10 shows how idempotency keys make POST safe to retry.
The status codes that matter
You need about a dozen. Group them by what the client should do next:
| Client should… | Code | When |
|---|---|---|
| use the result | 200 OK | Read worked, update worked, action worked |
| 201 Created | A POST created something. Add a Location header with its URL | |
| 202 Accepted | Work has started but isn't finished; here's where to check (§10) | |
| 204 No Content | It worked and there's nothing to send back (no body at all) | |
| fix the request | 400 Bad Request | Can't parse it: broken JSON, wrong content type |
| 422 Unprocessable Content | Parsed fine, but a value is invalid: quantity: 0, unknown product | |
| 409 Conflict | Valid, but clashes with the current state: cancelling a shipped order | |
| log in / ask for access | 401 Unauthorized | "Who are you?" Missing or expired credentials (it really means unauthenticated) |
| 403 Forbidden | "I know who you are, and you may not do this" | |
| stop asking | 404 Not Found | No such resource (or one the caller mustn't know exists) |
| wait, then retry | 429 Too Many Requests | This client is over its rate limit. Send Retry-After |
| 503 Service Unavailable | The server is temporarily overloaded or in maintenance. Send Retry-After | |
| report a bug | 500 Internal Server Error | Your code failed. The client can't fix it |
The first digit carries most of the meaning, so clients that only check 4xx vs 5xx still behave sensibly. That gives you the most important retry rule: 4xx means "don't retry the same request, it will fail the same way" (429 is the exception: it says "retry later"). 5xx and network errors may be worth retrying, carefully (Idempotency §09).
Practise
Pick the code you'd send. Some situations have an acceptable second-best answer, shown in amber:
POST /orders with a valid body. The order is saved.
Three mistakes to avoid
200 with an error inside
200 OK with {"success": false, "error": "not found"}. Every client, proxy, monitoring tool and retry library reads the status code first. They'll all think it worked.404 for an empty list
GET /orders?status=refunded with no matches is 200 with []. The collection exists; it's just empty.500 for bad input
A real orders API in Go and Node
POST /orders, GET /orders/{id}, POST /orders/{id}/cancel. Everything in §02–03, running.The shop keeps its orders in memory (a real one would use a database) and knows two products. Order ord_1 already exists and has shipped, so we can try to cancel it. Read the Go version first; the Node version does the same thing. (If Go syntax is new, the Go primer covers what you need.)
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
type Item struct {
SKU string `json:"sku"`
Quantity int `json:"quantity"`
}
type Order struct {
ID string `json:"id"`
Status string `json:"status"` // pending | paid | shipped | cancelled
Items []Item `json:"items"`
TotalCents int64 `json:"total_cents"` // money as whole cents, never a float
CreatedAt time.Time `json:"created_at"`
}
var prices = map[string]int64{"MUG-BLUE": 1200, "TEA-1KG": 2450}
type Store struct {
mu sync.Mutex // handlers run concurrently, so guard the map
orders map[string]*Order
nextID int
}
func main() {
s := &Store{orders: map[string]*Order{
"ord_1": {ID: "ord_1", Status: "shipped", Items: []Item{{"MUG-BLUE", 1}}, TotalCents: 1200,
CreatedAt: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)},
}, nextID: 2}
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", s.createOrder) // ← method + path pattern (Go 1.22+)
mux.HandleFunc("GET /orders/{id}", s.getOrder) // ← {id} is a wildcard
mux.HandleFunc("POST /orders/{id}/cancel", s.cancelOrder)
log.Fatal(http.ListenAndServe("localhost:8080", mux))
}
func (s *Store) getOrder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // "ord_1" for GET /orders/ord_1
s.mu.Lock()
o, ok := s.orders[id]
s.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "no order with id "+id)
return
}
writeJSON(w, http.StatusOK, o)
}
func (s *Store) createOrder(w http.ResponseWriter, r *http.Request) {
var in struct {
Items []Item `json:"items"`
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // refuse bodies over 1 MiB
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "body is not valid JSON")
return
}
if len(in.Items) == 0 {
writeError(w, http.StatusUnprocessableEntity, "an order needs at least one item")
return
}
var total int64
for _, it := range in.Items {
price, ok := prices[it.SKU]
if !ok || it.Quantity < 1 {
writeError(w, http.StatusUnprocessableEntity, "bad item "+it.SKU)
return
}
total += price * int64(it.Quantity)
}
s.mu.Lock()
o := &Order{ID: fmt.Sprintf("ord_%d", s.nextID), Status: "pending", Items: in.Items,
TotalCents: total, CreatedAt: time.Now().UTC().Truncate(time.Second)}
s.orders[o.ID] = o
s.nextID++
s.mu.Unlock()
w.Header().Set("Location", "/orders/"+o.ID) // where the new resource lives
writeJSON(w, http.StatusCreated, o)
}
func (s *Store) cancelOrder(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.orders[r.PathValue("id")]
switch {
case !ok:
writeError(w, http.StatusNotFound, "no such order")
case o.Status == "shipped":
writeError(w, http.StatusConflict, "order has already shipped")
default:
o.Status = "cancelled" // cancelling twice is fine: same end state
writeJSON(w, http.StatusOK, o)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json") // headers first…
w.WriteHeader(status) // …then the status…
json.NewEncoder(w).Encode(v) // …then the body
}
// A first, simple error body. §05 turns it into a proper standard.
func writeError(w http.ResponseWriter, status int, detail string) {
writeJSON(w, status, map[string]any{"status": status, "title": http.StatusText(status), "detail": detail})
}Running it
Start either server, then talk to it with curl (-i shows the response headers, -X picks the method, -d sends a body):
$ curl -i -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":2},{"sku":"TEA-1KG","quantity":1}]}'
HTTP/1.1 201 Created
Content-Type: application/json
Location: /orders/ord_2
{"id":"ord_2","status":"pending","items":[{"sku":"MUG-BLUE","quantity":2},{"sku":"TEA-1KG","quantity":1}],"total_cents":4850,"created_at":"2026-10-30T15:34:33Z"}
$ curl -i -X POST localhost:8080/orders/ord_1/cancel
HTTP/1.1 409 Conflict
Content-Type: application/json
{"detail":"order has already shipped","status":409,"title":"Conflict"}
$ curl -i -X POST localhost:8080/orders/ord_2/cancel
HTTP/1.1 200 OK
Content-Type: application/json
{"id":"ord_2","status":"cancelled", …}
$ curl -i -X DELETE localhost:8080/orders/ord_2
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Method Not AllowedThat's real output from the Go server (Date and Content-Length headers trimmed, and the long cancelled-order body shortened to …). Three things to notice:
- The
201comes with aLocationheader, so the client knows the new order's URL without building it. - Cancelling an already-cancelled order would also return
200: same end state, so the action is idempotent and safe to retry. - For
DELETE, Go's ServeMux answered 405 by itself, with anAllowheader listing the methods that do exist for that path (it addsHEADautomatically wherever you registeredGET). But that 405 body is plain text, not our JSON shape. A polished API wraps the mux so even router errors use the same error format (§05). Our Node version builds its own 405 and gets this right.
Bad input must get a 4xx, never a 500
Clients send strange things. Here's the Node server receiving five of them. Real output:
$ curl -s -X POST localhost:8080/orders -d '{"items":[{"sku":"toString","quantity":1}]}'
{"status":422,"title":"Unprocessable Entity","detail":"bad item toString"}
$ curl -s -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":1.5}]}'
{"status":422,"title":"Unprocessable Entity","detail":"bad item MUG-BLUE"}
$ curl -s -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":"2"}]}'
{"status":422,"title":"Unprocessable Entity","detail":"bad item MUG-BLUE"}
$ curl -s -X POST localhost:8080/orders -d '{"items":[null]}'
{"status":422,"title":"Unprocessable Entity","detail":"each item must be an object"}
$ curl -s -X POST localhost:8080/orders -d 'null'
{"status":422,"title":"Unprocessable Entity","detail":"an order needs at least one item"}Our first draft checked it.sku in prices and it.quantity >= 1. It accepted "toString" as a product (storing an order with "total_cents": null), charged 1.5 mugs, and answered {"items":[null]} with a 500, because reading null.sku threw. Go's typed decoding rejects most of these for free; in JavaScript, every check is yours to write.
A client
Here's the shop's web app creating an order with fetch (Node 18+ and every browser):
async function createOrder(items) {
const res = await fetch("http://localhost:8080/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
signal: AbortSignal.timeout(3000), // ← give up after 3 s
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.title}: ${body.detail}`);
return { order: body, location: res.headers.get("Location") };
}
console.log(await createOrder([{ sku: "TEA-1KG", quantity: 2 }]));
await createOrder([{ sku: "TEA-1KG", quantity: 0 }]).catch((err) => console.log("failed:", err.message));$ node client.mjs # against a freshly started server
{
order: {
id: 'ord_2',
status: 'pending',
items: [ [Object] ],
total_cents: 4900,
created_at: '2026-10-30T15:46:31Z'
},
location: '/orders/ord_2'
}
failed: 422 Unprocessable Entity: bad item TEA-1KGErrors clients can act on
What goes wrong without a standard
Teams that don't decide on an error shape end up with all of these in one API, because each endpoint was written by someone different:
{"error": "not found"}
{"message": "Invalid quantity", "code": 1042}
{"errors": ["items[1].quantity must be > 0"]}
"Internal Server Error"Every client now needs four parsers. Worse, the only way to tell errors apart is to compare message text, so fixing a typo in a message breaks a client somewhere.
Problem details (RFC 9457)
There's an Internet standard for this: problem details, RFC 9457 (2023; it replaced RFC 7807, which you'll still see cited). An error body is a JSON object, sent with the media type application/problem+json, with these fields:
| Field | For | Example |
|---|---|---|
type | programs: a URL that names this kind of error. Never changes. | "https://api.shop.example/problems/out-of-stock" |
title | humans: a short summary, the same every time for this type | "Item is out of stock" |
status | a copy of the HTTP status code | 409 |
detail | humans: what happened this time | "Only 1 of MUG-BLUE left; you asked for 2" |
instance | optional: an id for this occurrence, to quote in a support ticket | "/orders/ord_42" or a request id |
You may add any extension fields you like. The most useful is a list of field-level errors for validation failures.
Validation errors that point at the field
If the client sent three bad fields, tell it about all three at once, and say exactly where each one is. A JSON Pointer does that: #/items/1/quantity means "the quantity of the item at index 1 (the second item)". A form can then put a red message under the right input box.
// Problem is an RFC 9457 "problem details" body.
type Problem struct {
Type string `json:"type"` // a URL naming the kind of error
Title string `json:"title"` // short, same for every error of this type
Status int `json:"status"` // copy of the HTTP status
Detail string `json:"detail,omitempty"` // what went wrong this time
Errors []FieldError `json:"errors,omitempty"` // our extension: one entry per bad field
}
type FieldError struct {
Pointer string `json:"pointer"` // JSON Pointer to the bad field, e.g. "#/items/1/quantity"
Detail string `json:"detail"`
}
func writeProblem(w http.ResponseWriter, p Problem) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(p.Status)
json.NewEncoder(w).Encode(p)
}
func validateItems(items []Item) []FieldError {
var errs []FieldError
if len(items) == 0 {
errs = append(errs, FieldError{"#/items", "must contain at least one item"})
}
for i, it := range items {
if _, ok := prices[it.SKU]; !ok {
errs = append(errs, FieldError{fmt.Sprintf("#/items/%d/sku", i), "unknown SKU " + it.SKU})
}
if it.Quantity < 1 {
errs = append(errs, FieldError{fmt.Sprintf("#/items/%d/quantity", i), "must be 1 or more"})
}
}
return errs // nil when everything is fine
}
// in createOrder, after decoding:
if errs := validateItems(in.Items); errs != nil {
writeProblem(w, Problem{
Type: "https://api.shop.example/problems/validation",
Title: "Your request has invalid fields",
Status: http.StatusUnprocessableEntity,
Errors: errs, // ← report ALL problems at once, not just the first
})
return
}Real output from the Go server, for an order with an unknown product and a zero quantity:
$ curl -i -X POST localhost:8080/orders -d '{"items":[{"sku":"MUG-BLUE","quantity":2},{"sku":"MUG-RED","quantity":0}]}'
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{"type":"https://api.shop.example/problems/validation","title":"Your request has invalid fields","status":422,
"errors":[{"pointer":"#/items/1/sku","detail":"unknown SKU MUG-RED"},
{"pointer":"#/items/1/quantity","detail":"must be 1 or more"}]}(Line breaks added for reading.) The app can now highlight both problems on the second line of the cart, in one round trip.
Don't leak your internals
Here's a tempting shortcut, and what it produced when a client sent "quantity": "two":
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeProblem(w, Problem{Status: 400, Title: "Malformed JSON",
Detail: err.Error()}) // ← Go's own error text
return
}
// detail: "json: cannot unmarshal string into Go
// struct field Item.items.quantity of type int"if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
slog.Info("bad order body", "err", err) // log/slog, Go's built-in logger: detail stays in OUR logs
writeProblem(w, Problem{Status: 400, Title: "Malformed JSON",
Type: "https://api.shop.example/problems/malformed-json",
Detail: "The body must be a JSON object matching the Order schema."})
return
}The left one tells strangers your language, your struct names and your field types, and its wording changes whenever Go's does. Clients will start parsing it anyway. Errors from the database are worse: they can leak table names, queries or other customers' data.
For 5xx errors, always send a generic body ("something went wrong on our side") plus a request id the user can quote to support. Log the real error, with the same request id, on the server.
Pagination: offset vs cursor
Why from day one?
Remember the 2 GB GET /orders from §01. Adding pagination later is a breaking change: every existing client expects the full list in one response, and would silently show only the first page. So every collection endpoint starts paginated, with a default and a maximum page size (say 20 and 100), even if today it returns five items.
Two ways to ask for "the next page"
Offset pagination counts positions: "skip 40 rows, give me 20".
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 40; -- page 3It's what most people build first: easy to write, and it supports "jump to page 7". But positions move when the data changes. If a new order arrives while you're reading page 1, every row shifts down one place, and page 2 starts with a row you've already seen. If a row is deleted, everything shifts up, and one row is never shown to you.
Cursor pagination (also called keyset pagination) remembers where you were instead: "give me 20 rows that come after order #40 in this ordering".
SELECT * FROM orders WHERE id < 40 ORDER BY id DESC LIMIT 20; -- "after #40", newest firstNew rows at the top or deleted rows above you don't matter: "after #40" still means the same place. See it happen:
Try this sequence in offset mode: fetch page 1 → a new order arrives → fetch page 2. Page 2 repeats #10. Now reset and try fetch page 1 → the top order is deleted → fetch page 2: #9 is skipped and never shown. Switch to cursor mode and repeat both: no duplicates, no skips.
What the response looks like
The client shouldn't build the cursor itself. The server hands it out, ready-made, with each page:
{
"data": [
{ "id": 107, "status": "pending", "created_at": "2026-10-30T09:30:00Z" },
{ "id": 106, "status": "paid", "created_at": "2026-10-30T09:25:00Z" },
{ "id": 105, "status": "pending", "created_at": "2026-10-30T09:20:00Z" }
],
"next_cursor": "eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ"
}The client asks for GET /orders?cursor=eyJ0Ijoi… to get the next page. When next_cursor is null, there are no more pages.
That string is an opaque cursor: Base64 of a little JSON object holding the last row's sort values, here {"t":"2026-10-30T09:20:00Z","id":105} for order 105 (§07 shows the code and where these numeric ids come from). The {data, next_cursor} wrapper around the list is called an envelope. "Opaque" is a promise to yourself: clients must treat it as a meaningless token and only send it back. Say so in the docs. Then you can change what's inside (add a field, sign it, switch databases) without breaking anyone.
What you give up with cursors
Cursor pagination isn't free. Be honest about the trade-offs when you choose:
| Offset | Cursor | |
|---|---|---|
| Rows repeated or skipped while data changes | yes | no |
| Jump to page 7 | yes | no: only next (and previous, if you also build a "before" cursor) |
| Cost of a deep page | grows with the offset (§07) | the same for every page |
| "Page 3 of 812" | needs a COUNT(*), slow on big tables | same: needs a COUNT(*) |
| Sort by any column | yes | only by columns you built the cursor (and an index) for |
"Jump to page 7" is rarely needed. Infinite scroll, "load more", syncing every order to a Data warehouse, and an SDK helper that walks through all pages all go forward one page at a time. Admin tables with page numbers over a few thousand rows are the main place offset paging is still fine.
Keyset pagination: the SQL, the code, the numbers
WHERE (sort key) < (that key), and with the right index the database jumps straight there instead of counting its way down.This section uses SQL and database indexes. If indexes are new to you, Indexes §01 is a five-minute intro; the short version is "a sorted copy of some columns that lets the database find a row without reading the whole table".
Step 1: a sort order with no ties
Our shop lists orders newest first, by created_at. But two orders can be placed in the same second. If the cursor only says "after 09:10:00", the database can't tell which of the two 09:10:00 orders you already saw. One of them gets repeated or skipped: the very bug cursors were meant to fix.
The fix is a tiebreaker: add a unique column to the sort, so every row has exactly one position.
ORDER BY created_at DESC, id DESC -- id breaks ties between equal created_at valuesThe cursor then stores both values of the last row: {"t": "2026-10-30T09:20:00Z", "id": 105}.
Step 2: "after this row", in one comparison
"Rows after (09:20, #105)" in a newest-first list means: earlier created_at, or the same created_at with a smaller id. Postgres (and MySQL, SQLite) can write that as a single row comparison:
SELECT id, status, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-10-30 09:20:00+00', 105) -- compares created_at first, then id
ORDER BY created_at DESC, id DESC
LIMIT 21; -- 20 to show + 1 to learn "is there more?"(a, b) < (x, y) works like comparing words in a dictionary: compare the first values; only if they're equal, compare the second ones.
Step 3: an index in the same order
CREATE INDEX orders_created_id ON orders (created_at DESC, id DESC);With a Composite index in exactly the sort order, the database walks the index from the cursor position and stops after 21 entries. OFFSET 100000 uses the same index, but it has to walk past 100,000 entries and throw them away first. (Indexes §04 shows why a B-tree can jump to a key but can't jump to a position.)
The numbers
We loaded 1,000,000 orders into PostgreSQL 16 and asked for 20 rows at various depths. Here's EXPLAIN ANALYZE at row 100,000:
-- OFFSET: walks 100,020 index entries, keeps the last 20
Limit (actual time=15.705..15.709 rows=20 loops=1)
Buffers: shared hit=736 read=386
-> Index Scan using orders_created_id on orders (actual time=0.032..12.761 rows=100020 loops=1)
Execution Time: 15.732 ms
-- Keyset: jumps to the cursor, reads 20 entries
Limit (actual time=0.042..0.045 rows=20 loops=1)
Buffers: shared hit=3 read=1
-> Index Scan using orders_created_id on orders (actual time=0.041..0.043 rows=20 loops=1)
Index Cond: (ROW(created_at, id) < ROW('2025-11-09 12:00:30+00'::timestamp with time zone, 900001))
Execution Time: 0.077 msRead rows=100020 vs rows=20, and Buffers (8 KB pages touched; hit = found in Postgres's memory cache, read = fetched from the operating system or disk): about 1,100 vs 4. Across depths:
The OFFSET cost grows in a straight line with depth, and this is the friendly case: everything fits in memory, and nothing else is running. On a busy production database, deep pages from a crawler or a "sync everything" script can each hold a connection for hundreds of milliseconds.
The endpoint, in Go and Node
The page size is 3 here so the output stays short. Both files are complete programs; the outputs below come from exactly this code. Notice the two kinds of failure: a cursor the server can't decode is the client's mistake (400, don't retry), while a database error is ours (500).
package main
import (
"database/sql"
"encoding/base64"
"encoding/json"
"log"
"net/http"
"os"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // the Postgres driver; registers "pgx" with database/sql
)
// orderRow is one row of the orders table. Here ids are database numbers (bigint),
// not the "ord_2" strings of §04's in-memory shop.
type orderRow struct {
ID int64 `json:"id"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
CreatedAt time.Time `json:"created_at"`
}
// cursor = "the last row the client saw". Clients treat it as an opaque string.
type cursor struct {
CreatedAt time.Time `json:"t"`
ID int64 `json:"id"`
}
func encodeCursor(c cursor) string {
b, _ := json.Marshal(c) // can't fail for this struct
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeCursor(s string) (c cursor, err error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err == nil {
err = json.Unmarshal(b, &c)
}
return c, err
}
const pageSize = 3
func listOrders(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
after := cursor{CreatedAt: time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)} // "from the very top"
if s := r.URL.Query().Get("cursor"); s != "" {
var err error
if after, err = decodeCursor(s); err != nil {
http.Error(w, "bad cursor", http.StatusBadRequest) // the client's fault: 400
return
}
}
rows, err := db.QueryContext(r.Context(), `
SELECT id, status, total_cents, created_at FROM orders
WHERE (created_at, id) < ($1, $2) -- ← start right after the last row seen
ORDER BY created_at DESC, id DESC
LIMIT $3`, after.CreatedAt, after.ID, pageSize+1) // one extra row = "is there more?"
if err != nil {
log.Println("list orders:", err)
http.Error(w, "database error", http.StatusInternalServerError) // our fault: 500
return
}
defer rows.Close()
page := []orderRow{}
for rows.Next() {
var o orderRow
if err := rows.Scan(&o.ID, &o.Status, &o.TotalCents, &o.CreatedAt); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
page = append(page, o)
}
if err := rows.Err(); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
var next *string // stays nil → JSON null on the last page
if len(page) > pageSize {
page = page[:pageSize] // drop the extra row
last := page[pageSize-1]
c := encodeCursor(cursor{last.CreatedAt, last.ID})
next = &c
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"data": page, "next_cursor": next})
}
}
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) // e.g. postgres://localhost/shop
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /orders", listOrders(db))
log.Fatal(http.ListenAndServe("localhost:8080", mux))
}Real output from the Go server (some fields trimmed to …), against a 7-row table with two orders placed at the same second (#103 and #104):
$ curl -s localhost:8080/orders
{"data":[{"id":107,…,"created_at":"2026-10-30T09:30:00Z"},{"id":106,…},{"id":105,…,"created_at":"2026-10-30T09:20:00Z"}],
"next_cursor":"eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ"}
$ echo 'eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ==' | base64 -d # add back the == padding RawURLEncoding drops
{"t":"2026-10-30T09:20:00Z","id":105}
$ curl -s 'localhost:8080/orders?cursor=eyJ0IjoiMjAyNi0xMC0zMFQwOToyMDowMFoiLCJpZCI6MTA1fQ'
{"data":[{"id":104,…,"created_at":"2026-10-30T09:10:00Z"},{"id":103,…,"created_at":"2026-10-30T09:10:00Z"},{"id":102,…}],
"next_cursor":"eyJ0IjoiMjAyNi0xMC0zMFQwOTowNTowMFoiLCJpZCI6MTAyfQ"}
$ curl -s 'localhost:8080/orders?cursor=eyJ0IjoiMjAyNi0xMC0zMFQwOTowNTowMFoiLCJpZCI6MTAyfQ'
{"data":[{"id":101,…}],"next_cursor":null}Both 09:10:00 orders appear exactly once, in a fixed order, thanks to the id tiebreaker.
And the two failure paths, from the Node version. The first cursor is valid base64 but decodes to {"t":"nope","id":"x"}; for the second request, the server's database was stopped:
$ curl -s -w ' → %{http_code}\n' 'localhost:8080/orders?cursor=eyJ0Ijoibm9wZSIsImlkIjoieCJ9'
bad cursor → 400
$ curl -s -w ' → %{http_code}\n' localhost:8080/orders
database error → 500
# and in the server's own log:
list orders: connect ECONNREFUSED 127.0.0.1:15400(-w ' → %{http_code}\n' makes curl print the status code after the body.)
Walking every page from a client
Clients that need everything (a sync job, an export) loop until next_cursor is null. In JavaScript, an async generator hides the paging so the caller sees one stream of orders:
async function* allOrders(base) {
let cursor = null;
do {
const url = new URL("/orders", base);
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page = await res.json();
yield* page.data; // hand out every order on this page
cursor = page.next_cursor; // null on the last page → loop ends
} while (cursor);
}
for await (const o of allOrders("http://localhost:8080")) {
console.log(o.id, o.status, o.created_at);
}Changing an API without breaking anyone
What old clients actually see
Back to the rename from §01. The server now sends {"id":"ord_2","total_cents":4850,"discount":0}, and here's what code written against the old total field does with it. Real output:
type Order struct {
ID string `json:"id"`
Total int64 `json:"total"` // the app's model, written when the API sent "total"
}
var o Order
err := json.Unmarshal([]byte(body), &o)
fmt.Printf("err=%v order=%+v\n", err, o)
// err=<nil> order={ID:ord_2 Total:0} ← no error! Total is silently 0
dec := json.NewDecoder(strings.NewReader(body))
dec.DisallowUnknownFields() // a strict reader
fmt.Println("strict:", dec.Decode(&o))
// strict: json: unknown field "total_cents"Two lessons in one example:
- Removing or renaming a field doesn't fail loudly. Go fills in the Zero value, JavaScript gives
undefined, and the user sees $0.00 or $NaN. Your error dashboards stay green while customers complain. - Unknown fields are ignored by default, in both languages. Only
discountandtotal_centswere new, and the default decoder didn't mind. A client that rejects unknown fields (DisallowUnknownFields) breaks every time the server adds anything.
That second behaviour has a name: a tolerant reader. Clients should read the fields they need and ignore the rest. It's what makes additive changes safe, and it's why you should never turn on strict decoding for API responses in client code. (On the server, rejecting unknown request fields is a reasonable choice: it catches typos like quantiy early. But then you can't remove a request field without breaking clients that still send it.)
Safe or breaking? Drill it
Some are obvious; a few catch experienced teams:
discount_cents to the order responsetotal to total_centsGET /orders/{id}/invoiceshipping_address requiredgift_noteid from a number 42 to a string "ord_42""refunded"quantity per item from 100 to 10detail textThe pattern behind the answers: a change is safe if every response an old client could get is still one it understands, and every request it sends still succeeds with the same meaning. Behaviour counts too: default page sizes, sort orders and which errors happen are all part of the contract.
Renaming safely: expand, migrate, contract
You can rename total to total_cents. It just takes three releases instead of one, the same expand / contract recipe used for database columns (SQL vs NoSQL §12):
- Expand: send both fields with the same value. Accept both in requests.
- Migrate: ship clients that use the new name. Mark the old one deprecated in the docs and in the machine-readable API description (an OpenAPI file, §11).
- Measure: log which clients (API key, app version from
User-Agent) still use the old field or endpoint. - Contract: remove the old field once usage is low enough that you accept the remaining breakage, or never. For a public API, "never" is a common and perfectly respectable answer.
Announcing a removal: Deprecation and Sunset
Deprecation means "this still works, but it's going away: move off it". Docs and emails get missed, so announce it in the responses themselves, where client developers' tools and logs can see it. Two standard headers do this:
// deprecated wraps a handler to announce that it's going away.
func deprecated(next http.Handler, since, sunset time.Time, docs string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Deprecation", fmt.Sprintf("@%d", since.Unix())) // RFC 9745: deprecated since…
h.Set("Sunset", sunset.UTC().Format(http.TimeFormat)) // RFC 8594: may stop working after…
h.Add("Link", fmt.Sprintf(`<%s>; rel="deprecation"`, docs)) // where to read more
slog.Info("deprecated endpoint used", "path", r.URL.Path, "client", r.Header.Get("User-Agent"))
next.ServeHTTP(w, r)
})
}
mux.Handle("GET /v1/orders/{id}", deprecated(http.HandlerFunc(getOrderV1), // HandlerFunc turns a func into an http.Handler
time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC),
time.Date(2027, 5, 1, 0, 0, 0, 0, time.UTC),
"https://docs.shop.example/migrate-v2"))Deprecation: @1793491200
Sunset: Sat, 01 May 2027 00:00:00 GMT
Link: <https://docs.shop.example/migrate-v2>; rel="deprecation"
INFO deprecated endpoint used path=/v1/orders/ord_2 client="ShopApp/3.4 (iOS)"When you must break: versioning
Sometimes a change can't be made additive: the old design was wrong, or a security fix changes behaviour. Then you need API versioning: serve old and new behaviour side by side, and let each client choose. Three common ways to carry the choice:
| Style | Looks like | Good | Bad |
|---|---|---|---|
| URL | /v1/orders, /v2/orders | Obvious; easy to route, cache, log and try in a browser | Clients must change every URL; tempting to make v2 a total rewrite |
| Header / media type | Accept: application/vnd.shop.v2+json (a custom "vendor" media type: vnd. marks it as yours, +json says it's still JSON) | URLs stay stable | Invisible in logs and browser address bars; easy to forget |
| Dated versions | Stripe-Version: 2024-06-20 | Each client is pinned to the date it was built against; many small changes instead of one big v2 | You need a layer that translates between versions, forever |
Most teams should use URL major versions, and as few of them as possible. Each live version is a copy of the API you must test, document, secure and keep running. Stripe-style dated versions work beautifully, but only because Stripe built serious machinery for them. As Stripe has described publicly, their code is written for the newest version, and a chain of small "version change" modules transforms each response back to whatever date the client is pinned to.
REST vs GraphQL vs gRPC
A quick introduction to the two newcomers:
- GraphQL: one endpoint (
POST /graphql). The client sends a query that lists exactly the fields it wants, nested as deep as it likes, and the server returns JSON in exactly that shape. - gRPC: you describe functions and messages in a
.protofile, a tool generates client and server code, and the client callsGetOrder(...)like a local function. Messages travel as compact binary Protocol Buffers over HTTP/2. The gRPC topic covers it in depth.
One screen, four ways
The shop app's order screen shows the status, the total, each item's name and quantity, and the customer's name. Here's that screen's data fetched four ways:
GET /orders/ord_42
{"id":"ord_42","status":"paid","customer_id":"c7","currency":"USD","total_cents":4850,"discount_cents":0,"tax_cents":0,"created_at":"2026-10-30T09:30:00Z","updated_at":"2026-10-30T09:31:12Z","items":[{"sku":"MUG-BLUE","name":"Blue mug","quantity":2,"unit_price_cents":1200},{"sku":"TEA-1KG","name":"Green tea 1 kg","quantity":1,"unit_price_cents":2450}],"shipping_address":{"name":"Ada Lovelace","line1":"12 St James's Sq","city":"London","postcode":"SW1Y 4JH","country":"GB"}}GET /customers/c7 ← can only start after request 1 returns customer_id
{"id":"c7","name":"Ada Lovelace","email":"ada@example.com","phone":"+44 20 7946 0000","created_at":"2024-02-01T10:00:00Z","marketing_opt_in":false,"default_address_id":"addr_3"}Two words to take from that comparison:
- Over-fetching: the plain REST response carries every field of the order and the customer, though the screen needs six values. That's wasted bytes on a phone plan and wasted work on the server.
- Under-fetching: the customer's name needs a second request, which can only start after the first returns (it needs
customer_id). Each extra round trip on a mobile network can cost 100–300 ms.
GraphQL's bill arrives on the server
GraphQL makes the client's life easy by making the server's harder. Each field is filled in by a resolver function, and resolvers run once per object. Here's a real run with graphql-js, where customer is resolved separately for each order in a list of 3:
const rootValue = {
orders: ({ first }) =>
db.orders(first).then((rows) =>
rows.map((o) => ({ ...o, customer: () => db.customer(o.customerId) })), // ← one query per order
),
};
await graphql({ schema, rootValue, source: `{ orders(first: 3) { id customer { name } } }` });SQL: SELECT … FROM orders LIMIT 3
SQL: SELECT … FROM customers WHERE id = c7
SQL: SELECT … FROM customers WHERE id = c3
SQL: SELECT … FROM customers WHERE id = c7
queries: 4That's the N+1 query problem: 1 query for the list, plus 1 per order (and customer c7 was fetched twice). With first: 100 it's 101 queries. The standard fix is DataLoader, which batches all the customer lookups made during the same turn of the Event loop into a single WHERE id IN (…) query; N+1 §08 builds it.
Two more costs to plan for:
- Expensive queries. A client can write
{ customers { orders { items { product { reviews { author { orders … } } } } } } }. Public GraphQL APIs compute a query cost before running anything and reject queries over a budget. GitHub's GraphQL API does this, for example. - Caching. Every query is a
POSTto the same URL, so browsers and CDNs can't cache responses the way they cacheGET /products/7. The usual workaround is persisted queries: the app registers its queries ahead of time and sendsGET /graphql?id=orderScreen&variables=….
gRPC: fast, typed, and awkward for browsers
The same operation as a gRPC contract:
syntax = "proto3";
package shop.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc WatchOrder(GetOrderRequest) returns (stream OrderEvent); // server streaming
}
message GetOrderRequest { string id = 1; }
message Order {
string id = 1;
Status status = 2;
repeated Item items = 3;
Customer customer = 4;
int64 total_cents = 5;
}
message Item { string name = 1; int32 quantity = 2; }
message Customer { string name = 1; }
message OrderEvent { Status status = 1; }
enum Status { STATUS_UNSPECIFIED = 0; STATUS_PENDING = 1; STATUS_PAID = 2; STATUS_SHIPPED = 3; }The numbers (= 1, = 2) identify fields on the wire instead of names, so you can rename a field in the .proto without breaking anyone, but must never reuse a number (gRPC §05). Enum values carry the type's name as a prefix (STATUS_PAID) because in protobuf they all share one namespace; a later enum PaymentStatus { PAID = 1; } would otherwise clash. Encoded, the Order above is 63 bytes; the same fields as JSON are 167 bytes (we measured both). Browsers, however, can't speak gRPC directly: they need gRPC-Web or Connect (a gRPC-compatible protocol that also works over plain HTTP from a browser), plus a proxy or a server that supports it.
When each one wins
| REST + JSON | GraphQL | gRPC | |
|---|---|---|---|
| Best at | Public APIs, simple resources, anything cacheable | Many different screens over richly connected data | Service-to-service calls you control on both ends |
| Response shape decided by | the server, per endpoint | the client, per query | the shared .proto |
| HTTP caching / CDNs | built in for GET | needs persisted queries | none |
| Try it with curl | yes | yes (verbose) | needs grpcurl (a curl-like tool for gRPC) |
| Streaming | via SSE or WebSockets | subscriptions: a query that keeps sending updates (usually over WebSockets) | built in, both directions |
| Main server-side cost | many endpoints to design and version | resolvers, N+1, query cost limits | codegen, HTTP/2, proxies for browsers |
A sensible default for a product like the shop: REST + JSON for the public API (partners and scripts expect it), gRPC between internal services if you have many of them, and GraphQL only when many different client screens keep asking for new endpoint shapes. Often a backend for frontend (one small server per app that calls the internal APIs and returns exactly what each screen needs) solves the same problem with less machinery.
Reliability, designed into the API
1. Retries: idempotency keys
A phone sends POST /orders/ord_42/payments, then loses signal before the response arrives. Did the payment happen? The app can't know. If it retries a plain POST, the customer may be charged twice.
The fix is part of the API's design: the client generates a unique id for this intent (one checkout, not one HTTP attempt) and sends it as an Idempotency key header. The server remembers each key with the response it produced. A retry with the same key gets the saved response back, and the work is never done twice.
POST /orders/ord_42/payments
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
Content-Type: application/json
{"method": "card", "amount_cents": 4850}As the API designer, your decisions are: which endpoints accept a key (every POST that creates something or moves money), how long keys are kept (Stripe keeps them 24 hours), and what errors mean (409 while the first attempt is still running, 422 if the same key arrives with a different body). Idempotency §05 builds the full server side, including the database table and the race conditions.
2. Rate limits: tell clients where they stand
Every public API limits how fast each client may call it. The API design question is how you say so. Reject with 429 and a Retry-After header, and send rate-limit headers on every response so well-behaved clients can slow down before they hit the wall:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 2
RateLimit-Policy: "default";q=100;w=60
RateLimit: "default";r=0;t=2
{"type":"https://api.shop.example/problems/rate-limited","title":"Too many requests","status":429,
"detail":"This API key may make 100 requests per minute."}The RateLimit-* header syntax is still an IETF draft and has changed between versions; many APIs send X-RateLimit-Remaining and friends instead. Pick one, document it, and keep it stable. Rate limiting §09 covers the headers and a client that backs off politely.
3. Timeouts: on both sides
- Clients must set a Timeout on every call (
AbortSignal.timeout,http.Client{Timeout: …}). Your SDK and docs should do it by default. - Servers should bound how long any request may run, and document it: "requests that take longer than 30 s are cancelled". Load balancers and proxies often cut requests after 30–60 seconds anyway, whatever your code does (HTTP §11).
Which raises the next question: what if the work genuinely takes three minutes?
4. Slow work: 202 Accepted and a status resource
Don't hold the request open. Accept the job, answer at once, and give the client a URL to check on it. This is the long-running operation pattern:
1const sleep = (ms) =>2 new Promise((r) => setTimeout(r, ms));34async function runExport(params) {5 const start = await fetch("/exports", {6 method: "POST",7 headers: { "Content-Type": "application/json" },8 body: JSON.stringify(params),9 });10 if (!start.ok) throw new Error(`HTTP ${start.status}`);11 const url = start.headers.get("Location");1213 while (true) {14 const res = await fetch(url);15 if (!res.ok) throw new Error(`HTTP ${res.status}`);16 const job = await res.json();17 if (job.status === "succeeded")18 return job.result_url;19 if (job.status === "failed")20 throw new Error(job.error.detail);21 const secs = res.headers.get("Retry-After") ?? 2;22 await sleep(secs * 1000); // wait, then poll again23 }24}
The status resource is a normal resource, so all the earlier rules apply: it has a stable shape (status: queued | running | succeeded | failed, an optional progress, a result_url or a problem-details error); it's GET-able, so retries are free; and it should stay around for a while after the job finishes, so a client that was offline can still find the result.
5. Webhooks: when the API calls the client
Polling is wasteful when events are rare. A webhook reverses the direction: the customer registers a URL (https://partner.example/hooks/shop), and your server POSTs to it when something happens (order.paid, export.succeeded).
That URL is public, so anyone on the internet can POST fake "order.paid" events to it. The receiver needs proof that a request really came from you and wasn't changed. The standard tool is an HMAC signature: you and the customer share a secret, you compute HMAC-SHA256(secret, id + "." + timestamp + "." + body) and send the result as a header, and the receiver recomputes it and compares.
// sign is what the SENDER (our shop) does before POSTing the webhook.
func sign(secret []byte, id string, ts int64, body []byte) string {
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%s.%d.", id, ts) // id and timestamp are signed too
mac.Write(body)
return "v1=" + hex.EncodeToString(mac.Sum(nil))
}
// verify is what the RECEIVER does before trusting the body.
func verify(secret []byte, id, tsHeader, sigHeader string, body []byte, now time.Time) error {
ts, err := strconv.ParseInt(tsHeader, 10, 64)
if err != nil {
return errors.New("bad timestamp")
}
if age := now.Sub(time.Unix(ts, 0)); age > 5*time.Minute || age < -5*time.Minute {
return errors.New("timestamp too old or in the future") // blocks replays
}
want := sign(secret, id, ts, body)
if !hmac.Equal([]byte(want), []byte(sigHeader)) { // constant-time compare
return errors.New("signature mismatch")
}
return nil
}We signed a webhook with the Go sign and checked it with both verifiers. Real output:
Webhook-Signature: v1=cfff3f8ec75dabbbce635e22cd09bebe8d9ef16ede5342e7bce5412ab787e174
genuine: <nil>
tampered: signature mismatch
replayed: timestamp too old or in the future
JS verified the Go signature ✓
re-ordered keys: signature mismatchNow try to get a forged webhook past the receiver. The signatures below are computed live in your browser with real HMAC-SHA256:
Staff-level: contracts, guidelines and compatibility checks
API-first: write the contract down
In §07 our Go and Node servers returned the same order with different types (107 vs "107"). Nobody decided that; it just happened, because the contract only existed in people's heads. OpenAPI fixes that: a YAML file that describes every path, parameter, response and schema. In an API-first team, that file is written and reviewed before the code:
openapi: 3.1.0
info: { title: Shop API, version: 1.4.0 }
servers: [{ url: https://api.shop.example }]
security: [{ bearerAuth: [] }] # every endpoint needs a bearer token (e.g. a JWT)
paths:
/orders/{id}:
get:
operationId: getOrder
summary: Get one order
parameters:
- { name: id, in: path, required: true, schema: { type: string, example: ord_42 } }
responses:
"200":
description: The order
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
"404":
description: No such order (or not yours)
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer }
schemas:
Order:
type: object
required: [id, status, total_cents, created_at]
properties:
id: { type: string, example: ord_42 } # a string, decided once, for every server
status:
type: string
description: "Open enum: more values may be added. Show unknown ones as 'Processing'."
total_cents: { type: integer, format: int64, minimum: 0 }
total: { type: integer, deprecated: true, description: Use total_cents. Removed after 2027-05-01. }
created_at: { type: string, format: date-time }
Problem:
type: object
required: [type, title, status]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }That file passes redocly lint with its recommended rules (one warning: no license field). Everything else is then derived from the file:
- Code generation:
oapi-codegenturns the spec into Go types and a server interface your handlers implement, so a handler that returns the wrong shape doesn't compile.openapi-typescriptproduces TypeScript types for the web app. - Linting: Spectral (or Redocly) checks the spec against your style guide: every list is paginated, every error is
application/problem+json, every property issnake_case. - Mock servers let the app team build against the API on day one.
- Docs come from the same file, so they can't go stale.
A CI gate for breaking changes
Since the contract is a file, a tool can compare the version in a pull request with the one on main. We removed the deprecated total field and added a required currency query parameter, then ran oasdiff (v1.11.7):
$ oasdiff breaking openapi.yaml openapi-next.yaml --fail-on ERR
2 changes: 1 error, 1 warning, 0 info
error [new-required-request-parameter] at openapi-next.yaml
in API GET /orders/{id}
added the new required 'query' request parameter 'currency'
warning [response-optional-property-removed] at openapi-next.yaml
in API GET /orders/{id}
removed the optional property 'total' from the response with the '200' status
$ echo $?
1The exit code 1 fails the build, but look at why: only because of currency. Removing total is just a warning, and with --fail-on ERR a pull request that does nothing but remove it passes:
$ oasdiff breaking openapi.yaml removed-total.yaml --fail-on ERR
1 changes: 0 error, 1 warning, 0 info
warning [response-optional-property-removed] at removed-total.yaml
in API GET /orders/{id}
removed the optional property 'total' from the response with the '200' status
$ echo $?
0The tool's logic: total was never in the required list, so the spec promised clients only that it might be there. That's exactly the §01 outage, waved through. Two fixes, both checked with oasdiff:
- Fail on warnings too:
--fail-on WARNexits1for the same diff. Expect to mark some warnings as accepted on purpose. - Make the spec tell the truth: if a field is always sent, list it in
required. Then removing it is reported aserror [response-required-property-removed], and--fail-on ERRcatches it.
Note also what the first run did not flag: we also added a new currency property to the response and marked it required. For a response, "required" means "always present", which can only help readers, so that's an additive change. The tool knows the asymmetry from §08: requests may only get looser, responses may only get richer.
A spec diff only checks the spec. To check that servers really behave as the spec says, add contract tests: run each server in CI and validate real responses against the schemas (tools like Schemathesis generate the requests from the spec). Large teams also replay a sample of recorded production requests against the new version and diff the responses.
A style guide, so every endpoint feels the same
Clients learn your API once, then guess. Make the guesses right. A real guide is a few pages; these decisions matter most:
| Decide once | Our shop's choice |
|---|---|
| Naming | snake_case JSON, plural collection nouns, POST /…/{id}/verb for actions |
| IDs | prefixed strings (ord_…, pay_…), never numbers |
| Money | an integer count of minor units (cents for USD) plus a currency: {"total_cents": 4850, "currency": "USD"}. Never floats: 0.1 + 0.2 is 0.30000000000000004 |
| Time | RFC 3339 strings in UTC: "2026-10-30T09:30:00Z" |
| Lists | always paginated with ?cursor= / next_cursor, default 20, max 100 |
| Errors | problem details with a stable type URL (§05) |
| Enums | open, with documented fallback behaviour (§08) |
| Absent vs null | absent = "not included in this response"; null = "known to be empty". Document per field |
Filtering, sorting and sparse fieldsets
List endpoints grow query parameters. Keep them predictable:
GET /orders?status=paid&created_after=2026-10-01T00:00:00Z # filters: field names, ANDed together
GET /orders?sort=-created_at # sort: field name, "-" for descending
GET /orders/ord_42?fields=id,status,total_cents # sparse fieldset
GET /orders/ord_42?expand=customer # inline a related resourceEvery filter and sort you offer is a promise to keep it fast, which means an index for each combination clients use (Indexes §05), and a cursor format that includes the sort key (§07). So offer only the ones real screens need, and reject unknown sort fields with a 400 instead of silently ignoring them. Sparse fieldsets and expand give you most of GraphQL's flexibility (§09) while keeping cacheable GETs.
Where authentication and authorization go
Two different checks, in two different places (AuthN vs AuthZ covers the mechanisms):
- Authentication ("who is calling?") belongs at the edge, in one shared piece of middleware or an API gateway, so no endpoint can forget it. It turns a token from the
Authorizationheader into a caller identity. Credentials go in headers, never in URLs: URLs end up in logs, browser history andRefererheaders. - Authorization ("may this caller touch this order?") needs the order, so it belongs in the handler or the service layer (the code between handlers and the database that holds the business rules), next to the data. Forgetting it is the top API security bug, called BOLA (broken object-level authorization): the login check passes, and
/orders/ord_43happily shows someone else's order.
o, err := store.GetOrder(ctx, r.PathValue("id"))
if err != nil && !errors.Is(err, ErrNotFound) {
writeProblem(w, internalError) // the database failed: 500, and log err
return
}
if err != nil || o.CustomerID != caller.CustomerID {
writeProblem(w, notFound) // ← same answer for "missing" and "not yours": don't reveal it exists
return
}Prefixed, random-looking ids (ord_8Kx2… rather than 42) also make guessing harder, but they're not a substitute for the check.
Two clients editing the same order
A support agent and the customer both open order ord_42 and both send a PATCH. The second write silently overwrites the first. Optimistic concurrency lets the API catch this without locking:
GET /orders/ord_42 → 200 OK, ETag: "v7"
PATCH /orders/ord_42 If-Match: "v7" → 200 OK, ETag: "v8" (first writer wins)
PATCH /orders/ord_42 If-Match: "v7" → 412 Precondition Failed (second writer: re-read and retry)The ETag is a version label for the resource (a row-version counter works). The server applies the write only if the client's If-Match still matches, ideally inside the same database update: UPDATE orders SET … , version = version + 1 WHERE id = $1 AND version = $2.
Pitfalls gallery
A list endpoint with no limit
It's fine at launch and a 2 GB response three years later (§01). Adding pagination afterwards breaks every client that expects the full list. Every collection endpoint is paginated from its first release, with a default and a maximum page size (§06).
Your database table, served as JSON
{"order_id": 42, "cust_fk": 7, "is_del": 0,
"status_cd": 3, "upd_ts": 1793352000,
"internal_notes": "VIP, refund freely"}{"id": "ord_42", "customer_id": "c7",
"status": "shipped",
"updated_at": "2026-10-30T09:20:00Z"}Exposing columns as-is freezes your schema (now you can't rename cust_fk without a breaking change), exposes internal codes clients must decode, and sooner or later leaks a field that was never meant to leave the building. Map table rows to API types that you design on purpose (§02).
Money as a float
{"total": 48.5} invites 0.1 + 0.2 === 0.30000000000000004 bugs in every client, and says nothing about the currency. Send integer minor units and a currency: {"total_cents": 4850, "currency": "USD"}. (Some APIs send decimal strings like "48.50"; also fine. Floats are not.)
GET that changes things
GET /orders/ord_42/cancel looks convenient in an email link. Then a link-preview bot, an antivirus scanner or the browser's prefetching visits it, and the order is cancelled. GET must be safe (§03): use POST for anything that changes state, and put a confirmation page behind email links.
A chatty API
If showing one screen takes 1 + N requests (the order, then each item's product, then each product's image), every phone pays N extra round trips. Design responses around what screens need: embed small related data, offer expand= (§09), or add a purpose-built endpoint or a backend for frontend.
Clients that reject what they don't know
A client with DisallowUnknownFields, a strict schema validator on responses, or a switch on status with no default case turns every additive change into an outage (§08). Be a tolerant reader, and ship your SDKs that way.
Every error is 500, or every response is 200
500 for a validation failure pages your on-call engineer and makes clients retry a request that will never succeed. 200 {"success": false} makes every monitoring tool and retry library think things are fine. Status codes are for machines; use them (§03).
Ambiguous timestamps
"created": "10/11/26 09:30": is that October or November? Which time zone? Use RFC 3339 in UTC ("2026-10-30T09:30:00Z") everywhere, and name fields so their meaning is obvious (created_at, expires_at).
Starting at /v2 for every small change
Each version is a full copy of the API to run, test, document and secure. Teams that bump the major version for every rename end up maintaining four APIs. Exhaust additive options first (new field, new endpoint, new optional parameter), and treat v2 as a rare, planned migration (§08).
Checking that the user is logged in, but not that the order is theirs
The single most common API vulnerability (BOLA). Every handler that loads an object by id must check that the caller may see that object, and answer 404 when they may not (§11).
Check yourself
Predict the answer before you click. Q1 is covered in §01 and §08, Q2 in §03, Q3 in §06, Q4 in §08, Q5 in §10 and Q6 in §09.
App v3.4 has this code. The server just renamed total to total_cents.
const order = await (await fetch("/orders/ord_2")).json();
label.textContent = `$${(order.total / 100).toFixed(2)}`;POST /orders/ord_1/cancel. The request is well-formed, the user owns the order, but it has already shipped.
Newest first, 3 per page. You fetched page 1 (orders #12, #11, #10). Then order #13 is placed. You fetch OFFSET 3 LIMIT 3. What do you get?
You add "refunded" as a new possible value of order.status. The docs never said the list could grow.
The receiver uses verify from §10 (it throws on a bad signature) like this, and every genuine webhook is rejected with 401.
app.post("/hooks", express.json(), (req, res) => {
try {
verify(secret, req.headers, JSON.stringify(req.body)); // §10's verify: throws if bad
} catch {
return res.sendStatus(401);
}
// … handle the event
});Twenty internal Go services call each other thousands of times per second. You control every client and server, and want typed clients and streaming.
Cheat sheet
/orders, /orders/{id}, /orders/{id}/items. Methods say what to do. Real actions: POST /orders/{id}/cancel.application/problem+json: stable type to branch on, detail for humans, field errors with JSON Pointers. No stack traces.created_at, id), keyset SQL, matching index.| I want to… | Do this |
|---|---|
| Create something | POST /things → 201 Created, Location: /things/{id}, body = the new thing |
| Report invalid input | 422 (or 400) problem details with errors: [{pointer: "#/items/1/quantity", detail}] |
| Page through a list | ?cursor=… → {data: [...], next_cursor}; SQL WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT n+1 |
| Rename a field | Send both → ship clients using the new one → measure → remove the old one (or never) |
| Retire an endpoint | Deprecation: @<unix time>, Sunset: <HTTP date>, Link: <docs>; rel="deprecation", and log who still calls it |
| Make a POST safe to retry | Client sends Idempotency-Key: <uuid>; server stores key + response (Idempotency) |
| Run a 3-minute job | 202 Accepted + Location: /jobs/{id}; client polls with Retry-After, or gets a webhook |
| Prove a webhook is genuine | HMAC-SHA256 over id.timestamp.rawBody; constant-time compare; reject > 5 min old; dedup by id |
| Choose a style | Public: REST + JSON. Internal service-to-service: gRPC. Many varied screens on connected data: GraphQL or a BFF |