Filter concepts by levelShowing all levels.

System Design · API Design

API Design

Resources, HTTP semantics, querying a collection, versioning, pagination and reliable contracts

  • Resources, not verbs/orders/42, method carries the verb
  • Query a collectionfilter, sort, search, paginate — one endpoint
  • Cursor > offsetat scale, or on a changing dataset
  • Stable idsnever a mutable field like email

1.REST fundamentals: resources, HTTP semantics and status codes

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.

Key features

  • A resource is a noun addressed by a URL (/orders/42) — the HTTP method supplies the verb, so /getOrders or /deleteOrder/42 duplicates the verb the method already carries
  • GET, PUT, DELETE and HEAD are idempotent by definition — calling them once or five times leaves the server in the same state; POST and PATCH are not, by default
  • GET and HEAD are also safe — they must not change server state at all, which is what lets proxies and browsers cache or prefetch them freely
  • 2xx means success (200 OK, 201 Created, 204 No Content); 4xx means the client's request was the problem (400 Bad Request, 401 Unauthenticated, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity); 5xx means the server failed even though the request was valid
  • 201 Created should return a Location header pointing at the new resource's URL, rather than only a 200 with a body
  • Idempotency (an operation is safe to retry without a second effect) matters most for POST, since PUT/DELETE already get it for free from the method definition — full idempotency-key mechanics are a separate concept

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

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

Command

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

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.

2.Querying a collection: pagination, filtering, sorting and search

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.

Key features

  • Pagination limits response size and server load — returning an unbounded collection risks timeouts, huge payloads and accidental full-table scans
  • Filtering narrows a collection by field value, typically ?status=shipped or ?createdAfter=2026-01-01 — treat filter parameters as an AND unless the API explicitly documents OR semantics
  • Sorting controls order, typically ?sort=-createdAt (a leading - for descending) or ?sort=field,-otherField for multiple keys
  • Search (?q=blue+jacket) is a relevance-ranked text match, a fundamentally different operation from filtering — filtering is exact/range matching on structured fields, search is fuzzy matching on free text
  • Combine them in one request rather than layering separate endpoints: GET /orders?status=shipped&sort=-createdAt&limit=20&cursor=abc123 is one coherent call, not four

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

Command

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

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.

3.API versioning and backward compatibility

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.

Key features

  • Non-breaking changes need no version bump: adding an optional field, adding a new endpoint, adding a new optional query parameter, relaxing a validation rule
  • Breaking changes need a version bump: removing or renaming a field, changing a field's type, changing what a status code means, tightening validation on existing input
  • The three common versioning strategies: URL path (/v2/orders), a custom header (Api-Version: 2), or content negotiation (Accept: application/vnd.example.v2+json) — URL path is the most visible and the most common in practice
  • A deprecation window (an old version keeps working, clearly marked deprecated, for a stated period) is what makes a breaking change survivable for existing clients — a version bump without one only moves the breaking change to a later, unannounced date
  • Postel's robustness principle applies to API evolution too: a server should ignore fields it does not recognize in a request rather than rejecting the whole call, so adding a field never accidentally becomes a breaking change for old servers

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

Command

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

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.

4.Cursor pagination vs offset pagination

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.

Key features

  • Offset pagination (LIMIT/OFFSET in SQL terms) is simple to implement and lets a client jump to an arbitrary page number, but a large offset gets slower — the database still has to scan and discard every skipped row
  • Cursor pagination encodes a position (typically the last row's sort key, e.g. an id or timestamp) into an opaque token; the next request asks for rows after that cursor, so it never re-scans skipped rows
  • Offset pagination is unsafe on a changing dataset: inserts or deletes before the current page shift every row's offset, causing a client to see a duplicate row or silently miss one between two page requests
  • Cursor pagination cannot jump to an arbitrary page number (no "go to page 8") since a cursor only knows "after this specific row" — it is built for infinite-scroll and next-page navigation, not numbered pagination UI
  • Offset pagination is the right default for small, mostly-static datasets with a numbered-page UI (an admin table with a few thousand rows); cursor pagination is the right default for large or frequently-changing datasets (a social feed, an event log, an API consumed by other services)

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

Command

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

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.

5.Stable identifiers and explicit error contracts

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.

Key features

  • A stable identifier never changes for the life of the resource — using a mutable field (email, username, display name) as the primary identifier breaks every stored reference the moment that field is edited
  • Surrogate keys (an auto-increment integer, a UUID) make good stable identifiers precisely because nothing about them is meaningful or expected to change; natural keys (email, SKU string) often turn out to need editing later
  • An error contract needs a machine-readable code (e.g. "order_not_found"), separate from the human-readable message, so client code can switch on the code without string-matching prose that might get reworded
  • A consistent error contract also needs a stable place for field-level validation errors (which field, what was wrong) — a client rendering a form cannot highlight the right input from a single flat message string
  • RFC 7807 (Problem Details for HTTP APIs) is a standardized error shape — type, title, status, detail, instance — worth adopting or matching even if not used verbatim, so error handling is not reinvented per API

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"
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.

Command

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

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.

Remember

  • Model resources as nouns; let the HTTP method carry the verb, and let the status code (not the body) report the real outcome.
  • Filtering, sorting, search and pagination combine as query parameters on one collection endpoint, never as separate endpoints per combination.
  • Only a breaking change (removed/renamed field, changed type, newly required field) needs a version bump.
  • Offset pagination skips a row count and breaks under concurrent writes; cursor pagination points at a known row and stays correct — default to cursor at scale.
  • A resource's identifier must never change, and every error response should share one machine-readable contract.

Pro tip

  • Return a Location header with 201 Created — do not make the client guess or re-fetch the new resource's URL.
  • Give every error response a stable `code` field separate from its human-readable `message`, so client code can switch on it.

Next up

  • API Gateway and BFF
  • API Idempotency
  • Rate Limiting

Sources

  • RFC 7231 (HTTP semantics), RFC 7807 (Problem Details), Stripe API docs, Microsoft REST API guidelines
  • Checked 2026-08-25

Spotted a mistake on this page? Tell us.