Filter concepts by levelShowing all levels.

System Design · Section 54

API Design

Level
intermediate
Read
26 min
Concepts
5

A well-designed REST API models resources as nouns and lets the HTTP method carry the verb, using status codes — not response bodies — to report the true outcome. A collection endpoint combines filtering, sorting, search and pagination as query parameters on one endpoint rather than separate endpoints per combination, and the choice between cursor and offset pagination matters most once a dataset is large or changing underneath concurrent reads. An API also has to evolve without breaking existing callers — only a genuinely breaking change needs a version bump — and needs two contracts client code can actually rely on: identifiers that never change, and error responses that share one consistent, machine-readable shape.

This section

What is true here

  1. REST resources are nouns; the HTTP method (GET/POST/PUT/PATCH/DELETE) carries the verb, and the status code — not the body — reports the outcome.
  2. A collection endpoint combines filtering, sorting, search and pagination as query parameters, never as separate endpoints per combination.
  3. Only a breaking change (removed/renamed field, changed type, newly required field) needs an API version bump.
  4. Offset pagination is simple but unsafe under concurrent writes and slows down at large offsets; cursor pagination stays correct and fast, at the cost of arbitrary page jumps.
  5. A resource's identifier must never change once assigned, and every error response should follow one documented contract with a machine-readable code.

What you will be able to do

  • Model a resource-oriented API using HTTP methods and status codes correctly, including which methods are safe and idempotent
  • Design a collection endpoint that combines filtering, sorting, search and pagination without inventing endpoints per combination
  • Decide whether an API change needs a version bump, and choose a versioning strategy
  • Choose cursor pagination over offset pagination for a large or frequently-changing dataset, and explain why offset pagination silently skips or repeats rows under concurrent writes
  • Design stable resource identifiers and a consistent, machine-readable error contract

Designing the resource surface

Modeling resources with HTTP semantics and status codes, querying a collection, and evolving the API without breaking existing callers.

REST fundamentals: resources, HTTP semantics and status codes

coreintermediate

A REST API models a system as nouns, not verbs — resources like /orders or /orders/42, not endpoints like /getOrder. The HTTP method carries the verb (GET reads, POST creates, PUT replaces, PATCH partially updates, DELETE removes), and the status code reports the true outcome so a client can react correctly without parsing prose.

Think of it as

Think of a REST API like a library catalog, not a set of librarian commands. You do not ask a librarian to "performBookLookup" — you point at a shelf location (/books/isbn/9780134685991) and the action you want (checking it out, returning it, or reading its record) is a small, separate verb applied to that fixed address. The URL never changes meaning; only the verb applied to it does.

text
GET    /orders          -- list orders
POST   /orders          -- create an order
GET    /orders/{id}     -- read one order
PUT    /orders/{id}     -- replace one order
PATCH  /orders/{id}     -- partially update one order
DELETE /orders/{id}     -- delete one order

What we're doing: Model an order resource and its lifecycle using nouns, HTTP verbs and correct status codes instead of verb-shaped endpoints.

orders-api.httphttp
POST /orders HTTP/1.1
Content-Type: application/json

{ "customerId": "cus_8891", "items": [{"sku": "A1", "qty": 2}] }

HTTP/1.1 201 Created
Location: /orders/42
Content-Type: application/json

{ "id": 42, "status": "pending", "customerId": "cus_8891" }

GET /orders/42 HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json

{ "id": 42, "status": "pending", "customerId": "cus_8891" }

GET /orders/9999 HTTP/1.1

HTTP/1.1 404 Not Found
Content-Type: application/json

{ "error": "order_not_found", "message": "No order with id 9999" }
1
POST /orders creates a new order — the verb lives in the method, not in the path.
6
201 Created plus a Location header tells the client exactly where the new resource now lives.
19
404 reports a client-facing fact (this id does not exist) as a status code the client can branch on, rather than a message buried in a 200 body.

Why this works: Every response here carries its outcome in the status code first — a client can decide what to do (use the body, fix the request, retry) by checking one number before it even parses JSON.

Returning 200 OK for every response and burying the real outcome in the body

Wrong

http
GET /orders/9999 HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json

{ "success": false, "error": "order not found" }

Better

http
GET /orders/9999 HTTP/1.1

HTTP/1.1 404 Not Found
Content-Type: application/json

{ "error": "order_not_found", "message": "No order with id 9999" }

What you see: Generic HTTP tooling (caches, retry middleware, monitoring dashboards, API gateways) sees 200 and treats the call as a success — a failed lookup silently inflates a service's success-rate metric and never triggers a client's built-in error handling.

Why: The status code is the layer that infrastructure between client and server actually reads. A body-only error is invisible to anything that does not parse this specific API's JSON shape, which is every generic HTTP tool in the request path.

Status code families by what the client should do next

2xx Success

200 OK, 201 Created, 204 No Content

3xx Redirection

301 Moved, 304 Not Modified

4xx Client error

400, 401, 403, 404, 409, 422 — fix the request

5xx Server error

500, 503 — retry with backoff

  1. 2xx Success — 200 OK, 201 Created, 204 No Content
  2. 3xx Redirection — 301 Moved, 304 Not Modified
  3. 4xx Client error — 400, 401, 403, 404, 409, 422 — fix the request
  4. 5xx Server error — 500, 503 — retry with backoff

HTTP methods on a resource: verb, safety and idempotency

HTTP methods on a resource: verb, safety and idempotency
MethodMeaningSafe?Idempotent?
GETRead a resource or collectionYesYes
POSTCreate a resource, or a non-idempotent actionNoNo (unless an idempotency key is used)
PUTReplace a resource entirelyNoYes
PATCHPartially update a resourceNoNo, in general
DELETERemove a resourceNoYes

Together

http
PUT /orders/42 HTTP/1.1
Content-Type: application/json

{ "status": "shipped", "trackingId": "1Z999AA1" }

# Calling this twice with the same body leaves
# order 42 in the same final state both times --
# that is what "idempotent" means for PUT.

Status code families a client actually branches on

Status code families a client actually branches on
RangeMeaningExampleClient reaction
2xxRequest succeeded201 CreatedRead the response body / Location header
4xxClient sent a bad request404 Not FoundFix the request; do not blindly retry
5xxServer failed on a valid request503 Service UnavailableRetry with backoff; alert if persistent

Together

text
if status in 200..299: use body
elif status in 400..499: surface error to caller, do not retry as-is
elif status in 500..599: retry with backoff, or alert

Remember: Model nouns as resources (/orders/42) and let the HTTP method carry the verb — GET/PUT/DELETE are idempotent, POST/PATCH are not, and the status code (not the body) is what tells a client the true outcome.

See also: api gateway responsibilities

Querying a collection: pagination, filtering, sorting and search

standardintermediate

A collection endpoint like GET /orders needs a way to return a page at a time instead of every row at once, narrow the results (filtering), control their order (sorting), and find specific ones by text (search) — all expressed as query parameters, never by inventing new endpoints per combination.

Think of it as

Think of a collection endpoint like a spreadsheet with filters and sort turned on. You do not get a new spreadsheet for every filter combination — you apply criteria (status=shipped), pick a sort column (sort=-createdAt), and view one page of rows at a time. The underlying data and the endpoint stay the same; only the query parameters change what you see.

text
GET /orders?status=shipped        -- filter
GET /orders?sort=-createdAt        -- sort (- = descending)
GET /orders?q=blue+jacket          -- search
GET /orders?limit=20&cursor=abc    -- paginate

What we're doing: Combine filtering, sorting and pagination in one collection request and read what each parameter does.

orders-query.httphttp
GET /orders?status=shipped&sort=-createdAt&limit=2 HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": [
    { "id": 91, "status": "shipped", "createdAt": "2026-08-24" },
    { "id": 88, "status": "shipped", "createdAt": "2026-08-20" }
  ],
  "nextCursor": "eyJpZCI6ODh9"
}
1
status=shipped filters to an exact match; sort=-createdAt orders newest first; limit=2 caps the page size.
8
Only 2 of potentially thousands of shipped orders come back — the server did the filtering and sorting, not the client.
10
nextCursor is the token the client sends back to get the next page, covered in depth in cursor-vs-offset pagination.

Why this works: Each query parameter answers one independent question (which rows, what order, how many) and the server applies all three before the response is built — pushing that work to the client would mean downloading every row only to discard most of them.

One request, four mechanics — read it left to right

GET /orders?q=blue+jacket&status=shipped&sort=-createdAt&limit=20&cursor=eyJpZCI6ODh9

/orders

The collection — One endpoint. Every combination below is a query parameter on it — never a new path per combination.

q=blue+jacket

Search — Relevance-ranked match on free text. Fuzzy, and a different operation from the exact filter sitting right next to it.

status=shipped

Filter — Exact match on a structured field. Several filters read as an AND unless the API documents OR semantics.

sort=-createdAt

Sort — The leading minus means descending. A second key is comma-separated: sort=-createdAt,id.

limit=20

Page size — Caps the response. Leave it out and the server can be asked for the whole collection at once.

cursor=eyJpZCI6ODh9

Page position — The token the previous response handed back as nextCursor — it says where this page starts.

  • Whole: GET /orders?q=blue+jacket&status=shipped&sort=-createdAt&limit=20&cursor=eyJpZCI6ODh9
  • /orders — The collection: One endpoint. Every combination below is a query parameter on it — never a new path per combination.
  • q=blue+jacket — Search: Relevance-ranked match on free text. Fuzzy, and a different operation from the exact filter sitting right next to it.
  • status=shipped — Filter: Exact match on a structured field. Several filters read as an AND unless the API documents OR semantics.
  • sort=-createdAt — Sort: The leading minus means descending. A second key is comma-separated: sort=-createdAt,id.
  • limit=20 — Page size: Caps the response. Leave it out and the server can be asked for the whole collection at once.
  • cursor=eyJpZCI6ODh9 — Page position: The token the previous response handed back as nextCursor — it says where this page starts.

The four query mechanics on a collection endpoint

The four query mechanics on a collection endpoint
MechanicQuery param exampleWhat it controls
Pagination?limit=20&cursor=abc123How many rows come back, and which page
Filtering?status=shipped&createdAfter=2026-01-01Which rows match exactly (structured fields)
Sorting?sort=-createdAtThe order rows come back in
Search?q=blue+jacketRelevance-ranked free-text match, not exact filtering

Together

http
GET /orders?status=shipped&sort=-createdAt&limit=20 HTTP/1.1

# Filters to shipped orders, newest first,
# 20 per page -- three mechanics, one request.

Remember: A collection endpoint takes filtering (exact match on fields), sorting (order), search (fuzzy text match) and pagination (page size and position) as query parameters on one endpoint — never as separate endpoints per combination.

See also: cursor vs offset pagination · rest fundamentals

API versioning and backward compatibility

standardintermediate

An API changes over time, but clients already built against it cannot update instantly — versioning is how you introduce a breaking change without breaking every existing caller the moment you deploy it. A backward-compatible change (adding an optional field) needs no version bump; a breaking change (removing or renaming a field) does.

Think of it as

Think of API versioning like renovating a building while tenants still live in it. Adding a new door that nobody is forced to use (a new optional field) does not disturb anyone. Knocking down a load-bearing wall a tenant's furniture is bolted to (removing a field a client reads) needs its own wing built first (a new version) so existing tenants can move out on their own schedule instead of being buried in rubble on your schedule.

http
GET /v2/orders/42 HTTP/1.1              -- URL path versioning
GET /orders/42 HTTP/1.1
Api-Version: 2                            -- header versioning
GET /orders/42 HTTP/1.1
Accept: application/vnd.example.v2+json   -- content negotiation

What we're doing: Ship a breaking field rename without breaking clients still on v1.

versioned-orders.httphttp
GET /v1/orders/42 HTTP/1.1

HTTP/1.1 200 OK
{ "orderId": 42, "total": 59.99 }

GET /v2/orders/42 HTTP/1.1

HTTP/1.1 200 OK
{ "orderId": 42, "totalAmount": 59.99 }

# v1 is marked deprecated (Deprecation and Sunset
# headers) but keeps returning "total" unchanged
# until its announced retirement date.
1
Existing clients keep calling /v1 and keep getting the field name they were built against.
6
New clients (or v1 clients that have migrated) call /v2 and get the renamed field.
11
Both versions run simultaneously during the deprecation window — nobody is forced to migrate the instant v2 ships.

Why this works: Running both versions side by side turns a breaking change into a scheduled migration instead of an outage — every existing caller keeps working exactly as before until it chooses to move, or until the announced sunset date arrives.

A breaking rename, turned into a scheduled migration
  1. Jan

    v1 is the only version

    Clients read .total. Adding an optional .currency field here changes nothing for them, so it needs no version bump.

  2. Mar

    v2 ships alongside v1

    .total becomes .totalAmount. Both versions serve traffic from the same deploy — v1 keeps returning the field name its callers were built against.

  3. Mar

    v1 marked deprecated

    Deprecation and Sunset headers go out on every v1 response, so the retirement date reaches callers who never read a changelog.

  4. Sep

    v1 retired on the announced date

    The deprecation window is what made this survivable. A version bump without one only moves the same break to a day nobody was told about.

  1. Jan: v1 is the only version — Clients read .total. Adding an optional .currency field here changes nothing for them, so it needs no version bump.
  2. Mar: v2 ships alongside v1 — .total becomes .totalAmount. Both versions serve traffic from the same deploy — v1 keeps returning the field name its callers were built against.
  3. Mar: v1 marked deprecated — Deprecation and Sunset headers go out on every v1 response, so the retirement date reaches callers who never read a changelog.
  4. Sep: v1 retired on the announced date — The deprecation window is what made this survivable. A version bump without one only moves the same break to a day nobody was told about.

Breaking vs non-breaking API changes

Breaking vs non-breaking API changes
ChangeBreaking?Why
Add an optional response fieldNoExisting clients that ignore unknown fields are unaffected
Add a new endpointNoNo existing client calls it
Remove a response fieldYesA client reading that field now gets undefined/null or an error
Rename a fieldYesFunctionally identical to removing the old name
Change a field's type (string to object)YesClient deserialization breaks
Make an optional request field requiredYesExisting requests that omit it now fail validation

Together

json
// v1 response — client code reads .total
{ "orderId": 42, "total": 59.99 }

// Breaking: renamed to .totalAmount, v1 clients
// that read .total silently get undefined
{ "orderId": 42, "totalAmount": 59.99 }

// Non-breaking: added a field, v1 clients that
// never look at .currency are unaffected
{ "orderId": 42, "total": 59.99, "currency": "USD" }

Remember: Only breaking changes (removed/renamed fields, changed types, newly required fields) need a version bump — bump via URL path, header or content negotiation, and keep the old version alive through a stated deprecation window.

See also: rest fundamentals

Advertisement

Pagination at scale, and contracts a client can rely on

The cursor-vs-offset trade-off for large or changing datasets, plus the two contracts — stable identifiers and explicit error shapes — every client depends on.

Cursor pagination vs offset pagination

coreintermediate

Offset pagination (?limit=20&offset=40) tells the server "skip this many rows" — simple, but rows can shift between pages while a changing dataset is being read, so a row can be skipped or repeated. Cursor pagination (?limit=20&cursor=abc123) instead points at a specific row already seen and asks for what comes after it, which stays correct even while the underlying data changes.

Think of it as

Offset pagination is like being told "skip the first 40 people in this line and give me the next 20" — if 5 people leave the front of the line between your two requests, everyone shifts forward and you skip 5 people you never meant to skip. Cursor pagination is like saying "give me the 20 people standing right after this specific person I already know" — that person is a fixed reference point, so it does not matter how many people left the front of the line; you still get exactly who comes after your reference point.

text
GET /orders?limit=20&offset=40      -- offset pagination
GET /orders?limit=20&cursor=abc123   -- cursor pagination

What we're doing: Show offset pagination skipping a row when a delete happens between two page requests, and cursor pagination staying correct under the same delete.

pagination-under-writes.txttext
Starting rows, ordered by id: [1, 2, 3, 4, 5, 6]

-- OFFSET PAGINATION, page size 2 --
Request 1: GET /orders?limit=2&offset=0  -> [1, 2]
   (meanwhile, row 2 is deleted)
Request 2: GET /orders?limit=2&offset=2  -> [4, 5]
   Row 3 was never returned: after the delete, row 3
   shifted into offset-2's old position and was
   skipped when the server skipped "2 rows" again.

-- CURSOR PAGINATION, page size 2 --
Request 1: GET /orders?limit=2               -> [1, 2], nextCursor=2
   (meanwhile, row 2 is deleted)
Request 2: GET /orders?limit=2&cursor=2      -> [3, 4]
   Row 3 IS returned: the cursor means "rows with
   id > 2", which is unaffected by row 2 being
   deleted -- it was never a position count.
5
Offset 0 returns rows 1 and 2 -- normal first page.
7
The delete of row 2 shifts every later row one position to the left.
8
Offset 2 now skips rows 1 and 3 (the new "first two"), not rows 1 and 2 as the client assumed -- row 3 is silently lost.
15
Cursor=2 means "id greater than 2", a condition unaffected by row 2 itself being deleted.

Why this works: Offset counts positions, and positions shift when rows are inserted or deleted ahead of the current page. A cursor encodes a value (here, an id), and a WHERE id > cursor condition stays correct regardless of how many rows before that value come or go.

Using offset pagination on a feed that receives constant writes

Wrong

sql
-- page 2 of a live activity feed
SELECT * FROM events
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;

Better

sql
-- cursor = created_at (or id) of the last
-- event on the previous page
SELECT * FROM events
WHERE created_at < :cursor
ORDER BY created_at DESC
LIMIT 20;

What you see: Users scrolling a live feed intermittently see the same post twice or find posts silently missing between pages, worst during high-traffic periods when new rows are inserted fastest relative to how long a user spends reading each page.

Why: OFFSET 20 means "skip 20 rows from the current query result," and new inserts at the front of a DESC-ordered feed shift every existing row's offset down by one for each insert — the client's notion of "the next 20" and the server's recomputed "skip 20" stop agreeing the moment a single row is inserted between requests.

Offset pagination vs cursor pagination

Offset pagination

  • +Simple: skip N rows, take the next limit
  • +Supports jumping to an arbitrary page number
  • +Gets slower as the offset grows (scans skipped rows)
  • +Breaks under concurrent writes: rows shift, pages skip or repeat a row

Cursor pagination

  • Points at a known row, asks for what comes after it
  • No arbitrary page jump — only forward/backward from a cursor
  • Stays fast at any depth (index seek, not a scan)
  • Correct under concurrent writes — the cursor is a fixed reference point
  • Offset pagination
    • Simple: skip N rows, take the next limit
    • Supports jumping to an arbitrary page number
    • Gets slower as the offset grows (scans skipped rows)
    • Breaks under concurrent writes: rows shift, pages skip or repeat a row
  • Cursor pagination
    • Points at a known row, asks for what comes after it
    • No arbitrary page jump — only forward/backward from a cursor
    • Stays fast at any depth (index seek, not a scan)
    • Correct under concurrent writes — the cursor is a fixed reference point

Offset vs cursor pagination, side by side

Offset vs cursor pagination, side by side
PropertyOffset paginationCursor pagination
Query shape?limit=20&offset=40?limit=20&cursor=abc123
Jump to page NYes — offset = N * limitNo — only forward/backward from a known cursor
Correct under concurrent writesNo — can skip or repeat rowsYes — cursor is a fixed reference point
Performance at large page numbersDegrades — DB scans and discards skipped rowsStays flat — DB seeks directly to the cursor position
Best forSmall, mostly-static datasets, numbered-page UILarge or changing datasets, infinite scroll, feeds

Together

sql
-- offset: DB must scan and discard 10,000 rows
-- before it can return rows 10,001-10,020
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 10000;

-- cursor: DB seeks directly to id > 10000 using
-- the index, then reads 20 rows forward
SELECT * FROM orders WHERE id > 10000 ORDER BY id LIMIT 20;

Remember: Offset pagination skips a row count and gets slower and unsafe under concurrent writes as the offset grows; cursor pagination points at a known row and stays fast and correct — default to cursor for large or changing datasets, offset only for small, mostly-static ones with a numbered-page UI.

See also: querying a collection · rest fundamentals

Stable identifiers and explicit error contracts

standardintermediate

A resource's identifier (its id in the URL) must never change once assigned — a client, a bookmark, or another service's foreign key can all be holding that id indefinitely. An error contract is a consistent, documented JSON shape for every failure response, so a client can handle errors in code instead of parsing a different ad-hoc message from every endpoint.

Think of it as

A stable identifier is like a national passport number: it does not change if you move house, change your name, or renew the passport itself — anything that referenced you by that number still works. An error contract is like a standard incident report form used across an entire organization: every department fills in the same fields (code, message, details), so anyone reading a report knows exactly where to look, instead of every department inventing its own free-text memo.

json
{
  "error": {
    "code": "order_not_found",
    "message": "No order with id 9999",
    "requestId": "req_8f3a2b1c"
  }
}

What we're doing: Contrast a mutable natural key used as an identifier against a stable surrogate key, and show the resulting error contract when a lookup fails.

stable-id-and-error.httphttp
# BAD: email used as the identifier
GET /users/alice@example.com HTTP/1.1
# Breaks the moment Alice changes her email --
# every bookmark, webhook and stored reference
# using the old address now 404s.

# GOOD: stable surrogate id, email is just a field
GET /users/usr_9f83a1 HTTP/1.1

HTTP/1.1 200 OK
{ "id": "usr_9f83a1", "email": "alice@example.com" }

# error contract on a failed lookup
GET /users/usr_00000 HTTP/1.1

HTTP/1.1 404 Not Found
{
  "error": {
    "code": "user_not_found",
    "message": "No user with id usr_00000",
    "requestId": "req_8f3a2b1c"
  }
}
2
The URL itself encodes a field (email) that the user is allowed to change — any change breaks this URL for everyone already holding it.
8
usr_9f83a1 never changes even if every other field on the user record does, including email.
20
The error contract's code field lets client code branch on user_not_found without parsing the message string.

Why this works: An identifier and an error response are both contracts other systems depend on — changing either one's shape without warning breaks every caller that already parsed the old shape, which is exactly the failure stable identifiers and explicit error contracts exist to prevent.

What breaks when the identifier is a field the user can edit

Three systems store the identifier. Only one of these two designs survives the user editing their email.

  • Two panels, side by side, each showing a bookmark, a webhook and an orders foreign key all storing one identifier.
  • Left panel: the identifier is /users/alice@example.com. She changes her email, the URL becomes /users/alice.smith@example.com, and all three stored references now 404.
  • Right panel: the identifier is /users/usr_9f83a1 and email is an ordinary field. She changes her email, the id is unchanged, and all three stored references still resolve.

A consistent error contract vs an ad-hoc one

A consistent error contract vs an ad-hoc one
FieldPurposeExample
codeMachine-readable, stable across API versions"order_not_found"
messageHuman-readable, safe to display or log"No order with id 9999"
detailsField-level errors for validation failures[{"field": "email", "issue": "invalid_format"}]
requestIdCorrelates a client-reported error with server logs"req_8f3a2b1c"

Together

json
{
  "error": {
    "code": "validation_failed",
    "message": "Request failed validation",
    "details": [
      { "field": "email", "issue": "invalid_format" },
      { "field": "quantity", "issue": "must_be_positive" }
    ],
    "requestId": "req_8f3a2b1c"
  }
}

Remember: Identify a resource with a surrogate key that never changes (not a mutable field like email) and return every error as the same documented shape — a stable code plus a human message — so a client can handle failures in code, not by string-matching prose.

See also: rest fundamentals · entities and identifiers

Advertisement