Design clear, durable APIs: REST resources and status codes, cursor pagination, idempotency keys, versioning, error formats, and when to choose REST, gRPC or GraphQL.
What you'll learn: By the end of this lesson you will be able to design a REST API with sensible resources, methods and status codes. You will know why cursor pagination scales better than offset pagination, how idempotency keys make retries safe, and how to choose between REST, gRPC and GraphQL.
A restaurant menu is a contract. It lists what you can order, how it is described, and roughly what you will get. Customers do not need to know how the kitchen works, and the kitchen can change its stoves, its suppliers, even its chefs, as long as the menu keeps its promises. If the restaurant quietly swaps the burger for a salad under the same name, customers are rightly upset.
An API (application programming interface) is the menu for software. Mobile apps, web pages and other services order from it. Once other people depend on it, you cannot change it freely, which makes API design one of the most consequential and least reversible decisions in a system. This lesson covers how to design the contract well.
The core idea
An API contract is the agreed description of how clients and servers talk: which operations exist, what each accepts and returns, what errors look like, and what guarantees hold (such as "this call is safe to repeat"). A good contract has four properties.
Predictable. Similar things work in similar ways. Learn one endpoint and you can guess the next.
Hard to misuse. Dangerous behaviour needs explicit effort. Retrying a request should not charge a card twice.
Evolvable. You can add features without breaking existing clients.
Cheap at scale. It does not force work that gets slower as data grows (the offset pagination trap below).
The running example is a fitness-class booking service: members book spots in classes through a mobile app, and partner platforms call the same API. Most of the lesson is about HTTP APIs in the REST style, the most common starting point.
REST: resources, verbs and status codes
REST (representational state transfer) models your system as resources, which are nouns such as members, classes and bookings. Each has a URL, and you act on it with standard HTTP methods.
Method
Meaning
Safe to repeat?
Booking-service example
GET
Read a resource
Yes
GET /v1/classes/9f3
POST
Create a resource or trigger an action
No, unless you add idempotency
POST /v1/bookings
PUT
Replace a resource entirely
Yes
PUT /v1/members/42/avatar
PATCH
Change part of a resource
Depends on how it is defined
PATCH /v1/bookings/b81
DELETE
Remove a resource
Yes (deleting twice leaves it deleted)
DELETE /v1/bookings/b81
A few naming rules help.
Use nouns in URLs, not verbs. Prefer POST /v1/bookings over POST /v1/createBooking. The method already says what is happening.
Use plural collection names and nest where ownership is clear: GET /v1/members/42/bookings. Avoid nesting deeper than two levels.
Model relationships as resources. "Favourite a studio" can be PUT /v1/members/42/favourites/77, naturally repeatable: favouriting twice changes nothing.
Status codes
Range
Meaning
Common codes
2xx
Success
200 OK, 201 Created (with a Location header), 204 No Content
3xx
Redirection
301 Moved Permanently, 304 Not Modified
4xx
The client made a mistake
400 Bad Request, 401 Unauthorized (not authenticated), 403 Forbidden (not allowed), 404 Not Found, 409 Conflict, 422 Unprocessable Content, 429 Too Many Requests
5xx
The server failed
500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
The 4xx versus 5xx split matters operationally. A 4xx usually means "do not retry the same request unchanged, fix it" (429 is the exception: wait, then retry). A 5xx often means "this might work if you try again later". Monitoring, alerting and clients' retry logic all key off this, so never return 200 with an error hidden in the body.
Pagination: offset versus cursor
Any list endpoint that can grow must be paginated, or one response can crush both server and client.
Offset pagination says "skip N items, give me the next M": GET /v1/classes?limit=20&offset=40. It is simple and lets a client jump to page 7, but it has two problems.
It gets slower with depth. To return items 100,000 to 100,020 a typical database still walks past the first 100,000 rows.
It is unstable under change. If new items appear at the top while a client pages, everything shifts: the next page repeats items already seen, or skips some when items are deleted.
Cursor pagination (keyset or seek pagination) says "give me the next M items after this position". The server returns an opaque cursor and the client sends it back: GET /v1/classes?limit=20&cursor=eyJ0IjoxNzI5.... Internally the cursor encodes the last item seen, for example start time plus ID, so the query becomes "items after this position, in order, limit 20". That can use an index (a sorted lookup structure the database keeps) and stays fast on page 1 or page 10,000.
Approach
Use it when
Cost
Alternative
Offset
Small, mostly static lists; users need "jump to page N" (an admin table)
Slower at depth; shifts when data changes
Cursor
Cursor
Feeds, timelines, large or fast-changing lists, infinite scroll, partner sync
Cannot jump to an arbitrary page; needs a stable sort order
Offset for small data
Make the cursor opaque (clients must not parse it) so you can change what it encodes. Return {"items": [...], "next_cursor": "..."} and omit next_cursor on the last page. Always cap limit on the server, for example at 100.
Filtering and sorting
Use query parameters to narrow and order a collection:
GET /v1/classes?studio=12&starts_after=2026-11-01&sort=starts_at&limit=20
Keep conventions consistent across the whole API. Allow only fields you have indexed for filtering and sorting, otherwise a harmless-looking query becomes a full table scan that a client can trigger accidentally or deliberately. Document the allowed fields and return 400 for unsupported ones rather than silently ignoring them.
Idempotency and idempotency keys
An operation is idempotent if doing it once or many times has the same effect. GET, PUT and DELETE are idempotent by definition. POST usually is not, and that is where trouble starts.
A member taps "Book" on a gym's weak wifi. The request reaches the server, the response is lost, and the app retries. Did the first one succeed? The client cannot tell. If it retries it may book twice; if it does not, it may book nothing. Networks make this unavoidable, so the API must give the client a safe way to retry.
The standard answer is an idempotency key: the client generates a unique value (a random UUID) and sends it in a header, Idempotency-Key: 6f1c2d3a-.... The server remembers the key and the result.
sequenceDiagram
participant C as Client
participant S as Server
participant K as Key store
C->>S: POST /v1/bookings with key K1
S->>K: Seen K1 before
K-->>S: No
S->>S: Create booking and save result under K1
S-->>C: 201 Created
Note over C,S: Response lost, client retries
C->>S: POST /v1/bookings with key K1
S->>K: Seen K1 before
K-->>S: Yes with saved result
S-->>C: 201 Created with the same booking
How to read it: the second request does no booking work. The saved result is returned as if it were the first.
The check-and-store of the key must be atomic with creating the booking (all-or-nothing, no gap), or two simultaneous retries could both pass the check.
Keep keys for a bounded time, long enough to cover realistic retries (hours to a day), then expire them.
If the same key arrives with a different body, reject it: that is a client bug.
Return the original response for a repeat, so the client cannot tell the difference.
This is how an "exactly once effect" is built from at-least-once delivery: the client may send many times and the server makes repeats harmless.
Versioning
Clients cannot all upgrade at once, and you will need to change things.
Strategy
Example
Pros
Cons
URL path
/v1/classes, /v2/classes
Obvious, easy to route and cache
Duplicated endpoints; whole-API jumps
Header
Accept: application/vnd.booking.v2+json
Clean URLs
Harder to test by hand and see in logs
Query parameter
/classes?version=2
Easy to try
Easy to forget; mixes with filters
No versions, only additive change
Add fields and endpoints, never remove
No migration pain
Needs discipline; old mistakes live forever
A practical combination is a major version in the path (/v1) and a strict habit of additive changes. Whether a change needs a new version is a decision you can draw.
flowchart TD
C["Proposed API change"] --> Q1{"Does it only add optional fields or endpoints?"}
Q1 -->|yes| OK["Ship in the current version"]
Q1 -->|no| Q2{"Does it remove, rename or change meaning or tighten validation?"}
Q2 -->|no| OK
Q2 -->|yes| Q3{"Can you support old and new side by side?"}
Q3 -->|yes| V["Ship v2, announce a sunset date, track v1 usage"]
Q3 -->|no| R["Rethink the change"]
How to read it: most changes should exit at the first or second question. A new major version is the exception, and you only turn off the old one when usage has fallen.
Clients should ignore unknown fields. When a breaking change is needed, run both versions, publish a deprecation date, and watch /v1 usage fall before retiring it.
A consistent error format
Errors are part of the contract. Every error should look the same, so clients write one handler.
{"error":{"code":"class_full","message":"The class has no spots left.","details":[{"field":"class_id","capacity":20}],"request_id":"req_8c1f"}}
Include a stable machine-readable code (clients branch on it, so never reuse it with a new meaning), a human message, optional field-level details, and a request_id that support can find in logs. Never leak stack traces, SQL or internal hostnames. Pair the body with the right status code (here 409).
Authentication, authorisation and rate limits
Authentication proves who the caller is, typically with Authorization: Bearer <token>. Authorisation decides what that caller may do ("can member 42 cancel booking b81?"). Check both on every request, on the server, over HTTPS; never trust the client to enforce rules.
Rate limiting protects you from abuse and accidents. Retry-After is a standard header, commonly sent with 429 Too Many Requests (or 503), giving the seconds to wait or a date. Many APIs also send informational headers showing the limit, what remains and when it resets. Names vary between providers (X-RateLimit-* is a widespread convention, and a standard is in progress), so document yours. The Rate Limiting lesson covers the algorithms.
Choosing REST, gRPC or GraphQL
Style
Strengths
Costs
Good fit
REST over HTTP and JSON
Simple, universal tooling, easy to cache, readable
Can over-fetch or under-fetch; many round trips for complex screens
Public and partner APIs, web and mobile clients
gRPC
Compact binary format, strict typed contracts, streaming, fast service-to-service
Harder to call from browsers, less readable, needs tooling
Internal communication between services
GraphQL
The client asks for exactly the fields it needs in one request; typed schema
Harder to cache; expensive queries need depth and cost limits
Many client types with varied data needs
There is no winner. Many systems use REST at the public edge, gRPC between internal services, and sometimes GraphQL for one rich client. Pick for the consumer: who calls this, from where, and how often?
Example: a presigned upload for studio photos
Studios upload photos. Pushing 2 MB files through your application servers ties them up moving bytes. A common design lets the client send the file straight to object storage using a presigned URL: a time-limited URL that grants permission to upload one specific object. The API only handles small metadata calls.
sequenceDiagram
participant C as Client
participant A as API
participant O as Object storage
C->>A: POST /v1/photos with caption and size
A-->>C: 201 photo id and presigned upload URL
C->>O: PUT file bytes to presigned URL
O-->>C: 200 OK
C->>A: POST /v1/photos/9f3/complete
A->>O: Verify object exists
A-->>C: 202 Accepted and photo is processing
POST /v1/photos creates a record in state pending (accept an Idempotency-Key here too). complete verifies the object and enqueues thumbnail work, and GET /v1/photos/{id} shows status moving through pending, processing and ready. Return 202 Accepted for work that is accepted but not finished. Enforce the size limit in several places: state it when creating the record, apply any size condition your storage provider supports (support varies, so check its documentation), and verify the real size at complete.
In practice: a fitness-class booking service with a partner API
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A fitness-class booking service lets members reserve spots in yoga and spin classes at partner studios. Seven engineers run it. A mobile app is the main client, and about 30 partner platforms (corporate-wellness tools and studio websites) use the same HTTP API. The stack: a Kotlin service on Kubernetes, PostgreSQL, Redis, and Nginx ingress.
Three problems hurt. Duplicate bookings: assume 0.8% of bookings are duplicates, produced by mobile retries on poor gym wifi, creating around 300 support tickets a month. Partner syncs: the class-list endpoint uses offset pagination, p95 at page 500 is about 2.5 seconds (assumed) and partners report missing and repeated classes when schedules change mid-sync. Change risk: a partner wants a field renamed, and nobody knows which of 30 integrations would break.
Starting architecture
The data model behind the contract is small, and the booking resource is the one with the double-booking risk.
erDiagram
STUDIO ||--o{ CLASS_SESSION : hosts
CLASS_SESSION ||--o{ BOOKING : has
MEMBER ||--o{ BOOKING : makes
CLASS_SESSION {
string id PK
int capacity
int booked
}
BOOKING {
string id PK
string member_id FK
string session_id FK
string status
}
Options considered
Option
What it fixes
What it costs
Risk
Do nothing: tell clients not to retry, and document paging limits
Nothing
Free
Members still double-book; partners keep losing rows
Idempotency keys on POST /bookings, cursor pagination on list endpoints, additive-only /v1 with a deprecation process
Duplicates, deep-page slowness, change risk
A few weeks; a key table and a pagination migration
Partners must adopt cursors, and a key store is new load on PostgreSQL
New /v2 with GraphQL for everyone
Flexible queries
Months of work for 30 partners
Forces every integration to rewrite at once
The decision
Field
Record
Context
Retries duplicate bookings; offset paging is slow and unstable; no change policy.
Decision
Require Idempotency-Key on booking creation (scope: member or partner key, plus the key). Store it in PostgreSQL in the same transaction as the booking, with a unique constraint. Add cursor pagination alongside offset. Freeze /v1 to additive changes.
Consequences
Good: retries become harmless; deep pages stay fast. Bad: a key table to expire, and two pagination styles to support during migration.
Revisit when
The key table or its cleanup job affects booking latency, or /v1 carries a change that cannot be additive.
The capacity check uses one atomic statement, UPDATE class_sessions SET booked = booked + 1 WHERE id = ? AND booked < capacity, so two members racing for the last spot cannot both win. If it updates zero rows, the API returns 409 with class_full.
Rollout and verification
flowchart LR
A["Accept keys and log them only"] --> B["Enforce keys for the mobile app"]
B --> C["Cursor params added next to offset"]
C --> D["Partners migrate with usage tracking"]
D --> E["Sunset header on offset, then removal"]
Run in observe-only mode first: accept the key, record it, change nothing, and measure how many duplicates it would have caught. Enforce for the mobile app (which you control) before partners. Add cursor parameters beside offset, announce a retirement date with the Sunset header (a standard HTTP header for announcing when a resource will go away), and track usage per partner key. Dashboards: duplicate-booking rate (target near zero), idempotent-replay rate, p95 of list calls by page depth, traffic by API version and partner, and 409 and 422 rates. Add contract tests that call the API as a partner would, so a renamed field fails a build rather than an integration.
The 2 a.m. failure
A nightly cleanup job deletes idempotency keys older than 24 hours. It silently failed for several weeks after a deploy changed its schedule, and the first successful run then issues one huge DELETE of the backlog at 02:00. The page reads "POST /v1/bookings p99 above 3 s". The delete creates a large number of dead rows and heavy disk and write-ahead-log activity on the same table and index that every booking write touches.
sequenceDiagram
participant J as Cleanup job
participant DB as PostgreSQL
participant A as Booking API
J->>DB: DELETE all keys older than 24 h
Note over DB: Heavy I/O and many dead rows
A->>DB: Insert booking and key
DB-->>A: Slow response
A-->>A: Timeouts and client retries
First three checks: the booking latency and disk I/O panels, SELECT pid, state, wait_event_type, left(query, 60) FROM pg_stat_activity WHERE state <> 'idle' ORDER BY query_start; to spot the long-running delete, and a count of rows in the key table. Mitigation: cancel the job with SELECT pg_cancel_backend(pid);, then let the system recover. Follow-up: delete in small batches of a few thousand rows, or partition the key table by day and drop old partitions, add an index on created_at, and alert on the table's size.
Architect's notes
Retries are the default, not the exception. Mobile networks and partner schedulers retry. Design every POST that creates something to be safely repeatable.
The contract is a product. Ask in the review: who are the consumers, which fields do they depend on, and how will we know before we break them? Per-partner usage metrics answer that.
Prefer cursors for anything partners sync. Deep offset pages are a latent outage, and repeated or missing rows are a correctness bug that is hard to see.
Keep idempotency state cheap to expire. Put a retention window on it from day one and plan cleanup as a first-class job.
Own the deprecation. Someone must run the sunset conversation with partners. Defer /v2 until additive change truly cannot express the need.
Common mistakes
Verbs in URLs and inconsistent naming./getBooking and /Bookings make every endpoint a surprise. Fix: nouns, plural collections, standard methods.
Returning 200 for errors. Monitoring and retries cannot tell success from failure. Fix: accurate 4xx and 5xx codes plus a structured body.
Offset pagination on big, live lists. It slows with depth and repeats or skips items. Fix: cursors with a stable sort and a capped page size.
Unsafe retries on POST. Duplicate charges or bookings. Fix: idempotency keys stored atomically.
Breaking changes without a version. Renaming a field breaks apps you cannot force to update. Fix: additive changes, versioned majors, deprecation notices.
Unbounded results and unindexed filters. One query can tie up the database. Fix: cap page sizes and filter only on indexed fields.
Leaking internals. Exposed database IDs and stack traces couple clients to your implementation. Fix: stable public identifiers and sanitised errors.
Key takeaways
An API is a contract. Changing it later is costly, so design for predictability, safety and evolution.
Resources are nouns, methods are verbs. Use standard methods and accurate status codes; 4xx and 5xx mean different things to clients and monitors.
Cursors beat offsets at scale. Cursor pagination stays fast at any depth and is stable while data changes.
Cap and index. Limit page sizes and allow filtering only on fields the database can serve efficiently.
Idempotency keys make retries safe. The client reuses a unique key and the server returns the saved result.
Evolve by adding. Add optional fields freely; use a new version only for genuine breaking changes.
Errors need a stable shape. A machine-readable code, a message and a request ID serve clients and support.
Choose the style for the consumer. REST for broad public use, gRPC between internal services, GraphQL when varied clients need flexible queries.
Practice exercises
Exercise 1 — Starter (30 min): Design the REST endpoints for a comments feature on a class page: create, list, edit and delete a comment. For each, write the method, URL, success status code and one likely error status code. Check that every URL is a noun and every list endpoint is paginated.
Exercise 2 — Intermediate (2 hours): Write a full specification for GET /v1/classes using cursor pagination. Describe the request parameters (limit, cursor), the response shape, what the cursor encodes (for example, a start time plus an ID as a tie-breaker), what happens at the last page, and what the server does if the cursor is malformed or expired. Then write the database query shape (in words or SQL) and explain why an index on the sort fields makes it fast.
Exercise 3 — Advanced (half day): Design an idempotent POST /v1/payments endpoint. Draw a sequence diagram for a client retrying after a lost response, and a second for two simultaneous retries arriving at different servers. Specify where the key is stored, how the check is made atomic, how long keys live, and what happens when the same key is sent with a different body. List two failure modes (for example, the server crashes after charging but before saving the result) and how you would handle each.
Related reading
The Request Lifecycle - the previous lesson, covering the HTTP journey your API sits on.
Load Balancing - the next lesson: spreading API traffic across servers.
Rate Limiting - the algorithms behind 429 responses and limit headers.
Lesson 5 of 12 · System Design Foundations: From Zero to Your First Design
Design clear, durable APIs: REST resources and status codes, cursor pagination, idempotency keys, versioning, error formats, and when to choose REST, gRPC or GraphQL.
In the rest of this lesson
Idempotency and idempotency keys
Versioning
A consistent error format
Authentication, authorisation and rate limits
Choosing REST, gRPC or GraphQL
Example: a presigned upload for studio photos
Enrolling is free. It unlocks every lesson in System Design Foundations: From Zero to Your First Design, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.