Skip to content

    System Design

    System Design: API 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.

    By Published 15 min read

    Share

    This lesson is part of System Design Foundations: From Zero to Your First Design. Enroll for free, then sign in, to read it in full.

    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.

    1. It gets slower with depth. To return items 100,000 to 100,020 a typical database still walks past the first 100,000 rows.
    2. 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

    Fitness-class booking service, starting architectureMobile app to Nginx ingress; Partner platforms to Nginx ingress; Nginx ingress to Booking API; Booking API to PostgreSQL; Booking API to Redis; Booking API to Confirmation emailMobile appMobile appPartner platforms: about 30Partnerplatformsabout 30Nginx ingressNginx ingressBooking API: Kotlin serviceBooking APIKotlin servicePostgreSQLPostgreSQLRedisRedisConfirmation email: sent inlineConfirmationemailsent inline

    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.

    Enroll to keep reading

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    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.

    Join engineers building production AI systems

    Comments