Search Tech Journey

Find topics, journeys and posts

6-month learning plan56 / 130
back to blog
backend apisintermediate 55m read

S056 · REST API Design — Resources, Versioning, Idempotency

The design decisions that separate an API you can still maintain at v4 from one you have to burn down. Resources vs actions, versioning strategies that actually work, and idempotency keys as a first-class citizen. With a full working design for a real ‘orders’ API.

🧠SoftwareM06 · Backend & APIs· Session 056 of 130 90 min

🎯 Design a resource-oriented API for a real domain (orders + payments), pick a versioning strategy you can defend, and specify idempotency + pagination so clients survive network failures without duplicating data.

Why this session exists

Every API starts clean at v1. Two years later it has three versioning schemes, four ways to paginate, and a POST endpoint called /doStuff. REST design is the discipline that keeps that from happening — a small set of conventions (resources, verbs, status codes, versioning) that scale from three endpoints to three hundred without collapsing. Stripe, Twilio, GitHub, and Shopify all built billion-dollar businesses partly by getting these decisions right. This session teaches the decisions.

You will be able to
  • Model a domain as resources instead of actions, and name them consistently.
  • Choose between URL versioning, header versioning, and evolutionary compat with a defensible argument.
  • Design cursor pagination that survives inserts, deletes, and jumps in the underlying data.
  • Add idempotency keys to a POST endpoint so clients can safely retry.
  • Write an OpenAPI spec that generates docs, client SDKs, and mock servers from one file.

Prerequisites

  • S055 · HTTP Fundamentals — verbs, status codes, headers.
  • S045 · Data modelling (dimensional) — helps for choosing resources vs sub-resources.


(a) Intuition · 5 min

An API is a public street grid, not a maze
🌍 Real world

Cities that are easy to navigate follow a consistent grid: numbered streets, avenues at right angles, addresses that increment predictably. Newcomers find any building without asking.

Cities that grew organically (looking at you, historic city centres) are charming but have three ways to spell every road and require locals to guide you. Beautiful, but you cannot scale a delivery service on top of them.

💻 Code world

REST is the street grid for APIs. Resources are the buildings (/orders/42), verbs are the actions (GET, POST), and status codes are the standard signage. A newcomer can guess ‘DELETE /orders/42 probably cancels order 42’ without reading a single doc page.

Non-REST or RPC-style APIs work fine at small scale (‘/api?op=cancelOrder&id=42’), but every new endpoint requires new documentation because there's no shared grammar.

The design decisions that make or break an API

Every REST API lives or dies on these
  • Resource modelling — nouns not verbs, plural collections, sub-resources for compositions.
  • Versioning strategy — URL segment, custom header, or evolutionary compat. Pick one and commit.
  • Idempotency — POST is not idempotent by default; add an Idempotency-Key header for anything the client will retry.
  • Pagination — offset for small lists, cursor for anything that could grow past 10k rows.
  • Error format — one envelope shape across the whole API, with a machine-readable code + human message.
  • Spec-first — write OpenAPI before code so SDKs, docs, and mocks all stay in sync.

How the industry converged

  1. 2000
    Fielding's REST dissertation
    Formalises the architectural style. Ignored for years while SOAP + XML dominate.
  2. 2005
    Rails ‘resources’ scaffold
    DHH's Rails router makes REST verbs the default idiom. Adoption explodes.
  3. 2011
    Stripe launches with a public, versioned REST API
    Idempotency keys, pinned versions, deprecation policy — the template for the next decade.
  4. 2014
    Swagger becomes OpenAPI
    Vendor-neutral spec format. SDK generation and docs auto-derived from one YAML.
  5. 2020
    gRPC + GraphQL crowd in
    REST is no longer the only option, but ‘default’ for public APIs remains REST + OpenAPI.

(b) Visual walkthrough · 15 min

Resource tree for a real ‘orders’ API

Notice :cancel — Google's convention for actions that don't fit CRUD. Prefer this to POST /cancelOrder because it keeps the resource identity in the URL.

CRUD → HTTP mapping

Create

POST /orders

  • Body = new order
  • 201 Created + Location header
  • May accept Idempotency-Key
  • Returns the created resource
Read one

GET /orders/{id}

  • No body
  • 200 OK + full resource
  • Cacheable with ETag
  • 404 if not found
List

GET /orders?limit=&cursor=

  • Cursor pagination for growth
  • Filters as query params
  • Consider server-side default limit
  • Return next_cursor in response
Full replace

PUT /orders/{id}

  • Body = full resource
  • 200 OK or 204 No Content
  • Idempotent by contract
  • Rarely used outside admin tooling
Partial update

PATCH /orders/{id}

  • Body = JSON Merge Patch or JSON Patch
  • 200 OK + updated resource
  • Idempotent if well-designed
  • Prefer for user-visible mutations
Delete

DELETE /orders/{id}

  • No body
  • 204 No Content
  • Second DELETE = 404 (or 204 in ‘tolerant’ style)
  • Consider soft-delete for audit

Versioning strategies

Pick one — do not mix

URL versioning (/v1/orders)
Most common. Explicit, obvious, easy to route. Downside: technically not RESTful (resource identity changes across versions). Used by Stripe, Twilio, GitHub v3.
url
Header versioning (Accept: application/vnd.example.v2+json)
Cleaner URLs, keeps resource identity stable. Downside: invisible in a browser or curl by default. Used by GitHub v3 for content type variations.
header
Query param (?version=2)
Simple but pollutes cache keys and is easy to forget. Discouraged for anything user-facing.
query
Date-pinned (Stripe-Version: 2024-07-01)
Every request pins to a specific API snapshot. Server can evolve continuously. Complex but powerful — Stripe's model.
date
Evolutionary compat
Never break existing fields; only add. Requires strict discipline but avoids v2 entirely for years. Google's dominant approach.
evolve

Pagination models

11
Offset (?page=3&size=20)

Simple. Fails as data grows (‘page 500 of a live table’ = duplicates + skips). Fine for small admin lists.

22
Cursor (?after=abc&limit=20)

Server returns `next_cursor` opaque token. Stable under concurrent inserts. Default for anything that grows.

33
Keyset (?since_id=100&limit=20)

Cursor's simple cousin: use a monotonic primary key. Fast on indexed columns. Twitter, Slack.

44
Time-window (?from=2026-07-01&to=2026-07-31)

Best for event streams. Combine with cursor for pages within a window.


Common misconception
✗ What most people think

"REST means using HTTP verbs correctly and having nice plural nouns in the URL. If I have GET /users/123 instead of /getUser?id=123, my API is RESTful."

✓ What is actually true

URL aesthetics are the least important part. REST's actual constraints are: uniform interface, statelessness, cacheability, layered system, and client–server separation. The pressure that matters in practice is resource modelling — deciding what your nouns are — and evolvability: how the API changes without breaking clients you cannot redeploy.

Why the myth is so sticky

The myth is sticky because URL shape is the visible, checkable part — it fits in a lint rule and a code review comment, so it becomes the proxy for quality. It's also genuinely correlated with good design early on. It breaks down at the first operation that isn't a noun: "retry this job", "merge these accounts", "send the invoice". Teams that believe REST is about nouns spend hours inventing fake resources for these instead of accepting that some operations are operations.

Prove it to yourself

Take the hardest endpoint in your API and ask which constraint it actually satisfies:

# Is the response cacheable? Does it say so?
curl -sI https://api.example.com/v1/users/123 | grep -i 'cache-control\|etag'

# Is it stateless? Does it work with no prior request and no server memory?
curl -s -H 'Authorization: Bearer TOKEN' https://api.example.com/v1/users/123

# Does a conditional request actually save bandwidth?
curl -sI -H 'If-None-Match: "abc123"' https://api.example.com/v1/users/123

Most "RESTful" APIs return no ETag, no Cache-Control, and 200 on every conditional request. Pretty URLs, none of the constraints.

From first principles
Start with the question

Why must a public API version its contract rather than just "not breaking things"? Careful engineering sounds sufficient — it isn't.

  1. 1
    Once an API is public, clients exist that you cannot enumerate, cannot contact, and cannot redeploy.
    forced by · anyone with a token can integrate; there is no registry of who parsed your JSON last Tuesday
  2. 2
    Clients depend on properties you never intended to promise: field order, the presence of a field they ignore, the exact shape of an error, a numeric ID being numeric.
    forced by · observable behaviour becomes the contract regardless of what your documentation says (Hyrum's Law)
  3. 3
    Therefore "is this change breaking?" cannot be answered by inspecting your own code — it depends on client behaviour you cannot observe.
    forced by · breakage is defined at the consumer, and you have no visibility into consumer parsing logic
  4. 4
    The only way to make change safe is to keep the old observable behaviour reachable while the new one exists, so clients migrate on their own schedule.
    forced by · if old and new cannot coexist, every change forces synchronous global migration, which is impossible with unknown clients
  5. 5
    That requires an explicit selector — a version in the path, a header, or a date — because the server must know which contract this particular request expects.
    forced by · coexistence needs disambiguation; the server cannot infer intent from the request alone
⇒ Therefore

Therefore versioning is not bureaucracy, it is the mechanism that decouples your deploy schedule from your clients'. Its real purpose is not "supporting old clients" but "letting you ship at all".

And note what this predicts: additive changes need no version bump, because adding a field cannot break a client that ignores unknown fields — which is exactly why "clients must tolerate unknown fields" appears in every mature API guideline. It also predicts the cheapest possible versioning strategy: make almost every change additive, and you will need a version bump so rarely that the cost of supporting two versions never arises. Teams that version constantly usually have a modelling problem, not a versioning problem.

Mental modelResources are nouns, endpoints are a vocabulary

Design the nouns first: what things exist, what identifies them, how they contain each other. The endpoints then fall out almost mechanically — a collection, an item, and sub-collections for relationships. If designing the URL is hard, the resource model is wrong, not the URL.

The API is a vocabulary a stranger must be able to guess. If they know /orders/{id} exists, they should be able to guess /orders/{id}/items without reading anything.

  • Collections are plural, items are collection-plus-identifier, relationships are sub-collections. Never encode an action as a path segment on a noun — if you need an action, either it is a sub-resource with its own lifecycle (POST /jobs/{id}/retries) or you accept a verb and move on.
  • Pagination is not optional, and it is cursor-based. Offset pagination on a mutating collection silently skips and duplicates rows as items are inserted, and it degrades on large offsets because the database must still scan what it skips.
  • Errors are part of the contract: consistent shape, a stable machine-readable code, a human message, and a correlation ID. Clients switch on the code — so changing a code is a breaking change even though nothing in your schema moved.
  • Every mutating endpoint that a client might retry needs an idempotency key. The client cannot distinguish a lost request from a lost response, so it will retry, and without a key it will double-create.
🔔 Fires when you see

Fire this model when you see: a URL containing a verb · an endpoint returning a different shape depending on a query parameter · offset pagination over a live table · an error response that is a bare string · a "v2" that changed one field.

The tradeoff

A client needs a resource plus three of its relationships. Do you let them fetch each separately, embed everything by default, or offer field selection?

Strict resources, multiple round trips
+ you gain each endpoint has one job, responses are uniformly cacheable with their own TTL and ETag, and the server implementation stays simple with predictable per-endpoint cost.
− you pay the N+1 problem moves to the network. A list of 50 orders needing customer details becomes 51 requests, and on a high-latency mobile connection the round trips dominate everything else.
pick when server-to-server clients on a fast network, or when responses are genuinely cacheable and the cache hit rate is high
Embed related data by default
+ you gain one round trip gets everything; clients are simple and mobile latency is excellent because there is nothing to wait on sequentially.
− you pay every client pays for the heaviest client's needs. Payloads grow monotonically because nobody ever removes a field, and caching gets worse since the response changes whenever any embedded entity changes.
pick when a small number of known clients with similar needs — typically your own first-party mobile app against a backend-for-frontend
Field selection / expansion parameters
+ you gain clients request exactly what they need (?expand=customer,items&fields=id,total), so payload size matches actual requirements and clients evolve independently of the server.
− you pay the response shape is now combinatorial: caching keys on the parameter set, so hit rate collapses; testing must cover combinations; and the server needs a query planner to avoid the N+1 simply reappearing inside your own database.
pick when a public API with many unknown clients whose needs you cannot anticipate, and only once you have the engineering capacity to implement expansion without naive per-field queries
What a senior engineer actually does

Start with strict resources and add a small, explicit set of expansions when a real client demonstrates a real round-trip problem. Do not build general field selection speculatively — it is a query language in disguise, and if you find yourself building the whole thing, that is the signal to evaluate GraphQL rather than reinvent it badly.

The tell that you chose wrong: a client making 50 sequential calls to render one screen, or a response payload where 90% of the bytes are ignored by every caller. Instrument response size and per-screen request count; both are cheap to measure and both tell you which side of this tradeoff is currently hurting.


(c) Hands-on · 25 min

We'll design a small orders API with FastAPI — the fastest way to see resource modelling, idempotency, cursor pagination, and OpenAPI generation working together.

"""orders_api.py A minimal REST API demonstrating resource design,idempotency keys, cursor pagination, and standard error envelope. Run: pip install fastapi uvicorn uvicorn orders_api:app --reload Then visit: http://127.0.0.1:8000/docs auto-generated Swagger UI (from OpenAPI) http://127.0.0.1:8000/openapi.json the spec itself Example calls: curl -X POST -H 'Content-Type: application/json' \\ -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \\ -d '{"customer_id": 42, "items": [{"sku":"A1","qty":2}]}' \\ http://127.0.0.1:8000/v1/orders curl 'http://127.0.0.1:8000/v1/orders?limit=2' curl 'http://127.0.0.1:8000/v1/orders?limit=2&cursor=<next_cursor>'"""from __future__ import annotations import base64

Try it:

pip install fastapi uvicorn
uvicorn orders_api:app --reload
 
# create an order with an idempotency key
KEY=$(uuidgen)
curl -X POST http://127.0.0.1:8000/v1/orders \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $KEY" \
  -d '{"customer_id": 42, "items": [{"sku":"A1","qty":2}]}'
 
# retry the exact same call — same order returned, no duplicate
curl -X POST http://127.0.0.1:8000/v1/orders \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $KEY" \
  -d '{"customer_id": 42, "items": [{"sku":"A1","qty":2}]}'
 
# cursor pagination
curl 'http://127.0.0.1:8000/v1/orders?limit=1'

Then open http://127.0.0.1:8000/docs — the Swagger UI is generated for free from the Pydantic models. That's your API reference, mock server, and SDK-generator source of truth, all from one file.

Anatomy of the script

What each block teaches

URL prefix /v1
URL-based versioning. Every endpoint carries the version explicitly — routing, logging, and deprecation get simpler.
versioning
Pydantic models
Types define both the wire format and the validation. FastAPI auto-generates OpenAPI from them, so the spec never drifts from the code.
spec
IDEMPOTENCY_STORE + Header('Idempotency-Key')
Every mutating endpoint that a client might retry should support this. Store the (status, body) for at least 24h keyed by the header.
idempotency
error_response envelope
One shape for every error: type + code + message + request_id. Machines match on code, humans read message.
errors
encode_cursor / decode_cursor
Opaque tokens (base64 of a timestamp here). Clients treat them as ‘magic strings’; server owns their meaning and can evolve internals.
cursor
Custom method :cancel
Google's convention for actions that don't fit CRUD. Prefer this to POST /cancelOrder — keeps resource identity in the URL.
custom
Response headers (Location)
201 Created must include a Location header pointing to the new resource. Clients rely on it; SDKs auto-follow.
headers
Try itAdd a Deprecation + Sunset header for a legacy field

Add a Deprecation header when a legacy code path is used:

from datetime import datetime, timedelta, timezone
 
@app.get("/v1/orders/{order_id}", response_model=Order)
def get_order(order_id: str, accept_version: str = Header(default="v1", alias="Accept-Version")):
    order = ORDERS.get(order_id)
    if not order:
        raise HTTPException(status_code=404, detail={"code": "resource_missing", "message": f"No such order: {order_id}"})
    headers = {}
    if accept_version == "v0":
        sunset = (datetime.now(timezone.utc) + timedelta(days=90)).strftime("%a, %d %b %Y %H:%M:%S GMT")
        headers["Deprecation"] = "true"
        headers["Sunset"] = sunset
        headers["Link"] = '</docs/migration>; rel="deprecation"'
    return JSONResponse(content=order.model_dump(), headers=headers)

Now any client can programmatically detect they're on a deprecated path — automated migration reports become trivial.

💡 Hint · RFC 8594 defines `Deprecation: true` and `Sunset: <HTTP-date>`. Add them to the response headers on `GET /v1/orders/{id}` when a client-supplied `Accept-Version` is `v0`. Clients can now see deprecation without you sending emails.

(d) Production reality · 15 min

War story Stripe· 2011every payments API since
🔥 What broke

Payments must never charge twice. But the network is unreliable — a client can't tell if a failed request actually created the charge or not. Retrying naively risks duplicates; not retrying risks lost data.

Every early payments API punted this to the customer: "if unsure, query manually before retrying". Awful UX, error-prone integrations.

🧯 The fix
Stripe made Idempotency-Key a first-class header on every mutating endpoint. Clients generate a UUID per attempt, server stores the response for 24h keyed by (account, key). Duplicate submissions return the original response. Zero double-charges even under aggressive retries.
🎓 Lesson to steal
Idempotency on POST is not optional for anything financial. Ship it in v1 or add it at v2 with an awkward migration — but never later.
Post-mortem
War story GitHub· 2018v3 REST API → v4 GraphQL, 5-year migration
🔥 What broke
GitHub's v3 REST API returned monolithic ‘repo’ objects with dozens of fields. Mobile clients loaded 500 KB to render a page needing 3 fields; N+1 queries to fetch related data added multi-second latencies on lists.
🧯 The fix
GitHub introduced v4 as GraphQL alongside v3 REST. Existing REST clients kept working; new clients could request exactly the fields they needed. Five years later, both still ship — REST for CI/CD tooling, GraphQL for UIs. Neither has been sunset.
🎓 Lesson to steal
Version transitions in public APIs are measured in years, not weeks. Design v1 so you can add v2 alongside without breaking v1 clients — dual-stack is the reality.
Post-mortem
War story Common failure mode · everywherethe ‘v2 that never shipped’
🔥 What broke
A team designs v2 with breaking changes accumulated over 18 months. It requires clients to migrate manually. Six months after launch, 3 % of traffic is on v2 and support has to keep v1 alive indefinitely. v3 discussions start; nobody wants to repeat the pain.
🧯 The fix

Two patterns that prevent the trap:

  1. Evolutionary compat (Google): never break existing fields, only add. Semantically-versioned changes stay backwards-compatible for years.
  2. Date-pinned versions (Stripe): the server evolves continuously; each request pins to a snapshot. Clients migrate by bumping one string.
🎓 Lesson to steal
Big-bang v2 launches are almost always regrets. Prefer additive evolution or per-request version pinning.

Where this shows up in the rest of the plan

REST design is the substrate for public-facing services
S055 · HTTP fundamentals
The verbs and status codes this session's decisions build on.
S057 · GraphQL
The alternative for read-heavy, over-fetch-prone UIs. Same design discipline, different shape.
S058 · gRPC & Protobuf
Contract-first, code-generated APIs — the internal counterpart to REST for polyglot microservices.
S059 · Auth (OAuth / JWT)
Every REST API bolts identity on via headers designed here.
S073 · API gateway
Rate limiting, auth, and versioning enforcement happen at the gateway layer.
S128 · System design capstone
Every capstone system exposes at least one REST or gRPC surface using these patterns.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Move on when you can teach these without notes:

  1. Why do we model APIs around resources instead of actions?
  2. What is an idempotency key and when is it non-negotiable?
  3. Why is offset pagination a trap once your data grows?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.