Skip to content

API Design

Resources over verbs, status codes that mean something, idempotency, and versioning you will not regret.

Intermediate17 minUpdated 5 October 2026

An API is a promise. Once someone builds against it you cannot casually change it — their code breaks, in production, on their schedule rather than yours.

That permanence is what makes API design worth slowing down for. Internal code can be refactored freely. A published endpoint is close to forever.

Resources, not actions

The most common beginner API is a list of function names:

POST /createUser
POST /updateUser
POST /deleteUser
POST /getUserById
POST /getUsersByCompany

This grows without limit — every capability invents a verb, and nobody can guess the convention.

REST's core idea is to name things and let HTTP methods supply the verbs:

POST   /users            create
GET    /users/42         read
PATCH  /users/42         partial update
DELETE /users/42         delete
GET    /users?company=7  filtered list

Five methods over one noun replace five invented verbs, and a developer who has seen any REST API can guess the rest.

Conventions worth adopting: plural nouns for collections, nesting only one level for genuine containment (/users/42/orders), and query parameters for filtering, sorting and pagination rather than new paths.

Status codes carry meaning

Returning 200 OK with {"error": "not found"} forces every client to parse the body to learn whether the call worked. Status codes exist so they do not have to.

CodeMeaningUse when
200OKSuccessful read or update
201CreatedA new resource exists; include its location
204No ContentSuccess with nothing to return, e.g. delete
400Bad RequestMalformed or invalid input
401UnauthorizedNot authenticated — we do not know who you are
403ForbiddenAuthenticated, but not allowed
404Not FoundNo such resource
409ConflictViolates current state, e.g. duplicate email
429Too Many RequestsRate limited — include Retry-After
500Server ErrorWe broke; the client did nothing wrong

The 401 versus 403 distinction matters because the correct client reaction differs: 401 means "log in and try again", 403 means "stop, logging in will not help".

The 4xx versus 5xx split matters more. 4xx means the client must change something; 5xx means retry later. Returning 500 for a validation failure invites clients to retry invalid input forever.

Error bodies clients can act on

The status code is the category; the body carries the detail.

{
  "error": {
    "code": "insufficient_credits",
    "message": "Your account has 50 credits but this order requires 100.",
    "details": [{ "field": "total", "reason": "exceeds_balance" }]
  }
}

Three things make this useful: a stable machine-readable code clients branch on without string-matching prose, a human-readable message for logs and developers, and field-level details so a form can highlight the right input.

Keep the error shape identical across every endpoint, so clients write one error handler rather than thirty.

Idempotency: the property that prevents double charges

A request is idempotent if making it twice has the same effect as once.

This matters because networks are unreliable. A client sends "charge ₹1000", the response is lost in transit, and the client cannot tell whether the charge happened. If it retries, is the customer charged twice?

GET, PUT and DELETE are naturally idempotent. POST is not — two creates make two resources.

For unsafe operations, let the client supply an idempotency key:

POST /payments
Idempotency-Key: 8f14e45f-ea8b-4f1a-9c2e-2b0f7a1d3e4c

{ "amount": 100000, "currency": "INR" }

The server stores the key with its result. A repeat of the same key returns the original response instead of doing the work again, so the client can retry safely — which is exactly what you want it to do.

Conceptual diagramkey abcnoyesClientServerSeen this key?Do the work,store the resultReturn stored result,charge nothing
Diagram, as text
  • Client leads to Server (key abc)
  • Server leads to Seen this key?
  • Seen this key? leads to Do the work, store the result (no)
  • Seen this key? leads to Return stored result, charge nothing (yes)

Versioning

You will need a breaking change eventually. Plan for it before you have clients.

URL versioning — /v1/users — is most common: explicit, visible in logs, easy to route. Header versioning keeps URLs clean but is invisible in a browser and harder to debug.

Whichever you pick, the disciplines are the same:

  • Additive changes are not breaking. Adding a field is safe when clients ignore unknown fields. Removing or renaming one is not.
  • Never change the meaning of an existing field. Turning status: "active" into status: 1 silently breaks every consumer.
  • Deprecate on a published timeline and keep the old version running for a stated period.

REST or GraphQL

Different trade-offs rather than a rivalry.

REST gives fixed endpoints with fixed shapes: simple, cacheable by URL at the HTTP layer, easy to monitor per endpoint. Its weakness is over-fetching and under-fetching — a mobile client needing three fields still receives the whole object, and one screen may take four round trips.

GraphQL lets the client request exactly the fields it needs in a single call, which genuinely helps mobile clients and complex screens. The costs are real: HTTP caching no longer works by URL, one badly written query can be enormously expensive, and you need depth limits and cost analysis to stay safe.

Default to REST. Choose GraphQL when many diverse clients have genuinely different data needs and you can afford to operate it properly.

Details that separate good APIs from adequate ones

Paginate every list endpoint from day one. Adding pagination later is a breaking change.

Return the created resource on 201 with a Location header, saving the client a round trip.

Use ISO 8601 with a time zone for every timestamp: 2024-06-10T14:30:00Z. Never epoch integers or locale-formatted strings.

Be consistent about naming. Pick snake_case or camelCase and apply it everywhere; mixing them taxes every consumer forever.

Never expose internal identifiers or raw errors. Stack traces and database messages leak your schema to attackers.

Check your understanding

Common mistakes

Verbs in paths. /getUser and /users/42/activate both expose functions rather than resources. For genuine actions, model the outcome as a resource: POST /users/42/activations.

Leaking database structure. Your API is a contract, not a mirror of your schema — use DTOs.

Inconsistent collection responses. Sometimes a bare array, sometimes { data: [...] }. Pick one and keep it.

No rate limiting. An unprotected public endpoint will eventually be abused.

Undocumented behaviour. An endpoint whose error cases live only in someone's head is unfinished. Generate documentation from code so it cannot drift.

Practice

You must add a required field to a request body used by a mobile app you cannot force-upgrade. Describe how, without breaking existing clients or versioning the whole API.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

'Required' is a property you can phase in rather than switch on.

Show a strong answer

The constraint is that old clients will keep sending requests without the field for years. So the field cannot be required at the wire level on day one, whatever the eventual intent.

A four-stage rollout:

  1. Add it as optional, with a documented default. Old clients omit it and get the default; new clients send it. Nothing breaks and both populations are correct. Choose the default to match what old clients implicitly meant, not what is most convenient.
  2. Measure who omits it. Instrument the proportion of requests without the field, broken down by client version. This is the number that decides everything afterwards, and without it the rest is guesswork.
  3. Push adoption. Ship the new client, and if the field matters enough, warn — a deprecation header on responses that omitted it, and a message in the developer documentation with a date.
  4. Make it required only when omission has fallen below what you are willing to break, and even then prefer failing loudly with a clear error naming the field and linking to the migration note.

If the field genuinely cannot have a sensible default — it changes the meaning of the operation — then the honest answer is that this is a new operation, not a modified one. Add a new endpoint or a new resource, leave the old one working, and deprecate it on a stated timeline. Forcing a semantic change through an existing contract is how clients break in ways that look like your outage.

What I would avoid: versioning the entire API for one field. It doubles the surface to maintain and tests, and it forces clients to migrate everything at once to adopt one change.

Design pagination for an endpoint listing a user's transactions, newest first, where new transactions arrive constantly. Compare offset and cursor approaches concretely.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Work out what happens to page 2 when three rows are inserted while the user reads page 1.

Show a strong answer

Offset pagination — ?page=2&limit=20 — translates to OFFSET 20 LIMIT 20.

The concrete failure: the user reads page 1 (items 1–20). Three new transactions arrive at the top. They request page 2, which skips the first 20 rows of the new ordering — so items that were 18, 19 and 20 have shifted to 21, 22 and 23 and are shown again. Items can also be skipped entirely if rows are deleted. On a feed of financial records, showing the same transaction twice is not a cosmetic bug.

It also degrades: OFFSET 100000 requires the database to count and discard 100,000 rows before returning 20.

Cursor pagination — the response includes an opaque cursor encoding the position of the last item, and the next request asks for rows after it: WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 20.

  • Insertions do not disturb it, because the cursor names a position in the data rather than a count of rows skipped.
  • It stays fast at any depth, because the index seeks directly to the position instead of counting.
  • The composite (created_at, id) matters: timestamps collide, and without a tiebreaker rows with identical timestamps can be skipped or repeated.

Choose cursor here, and design two details deliberately: make the cursor opaque so its encoding can change without breaking clients, and decide what happens when a cursor refers to a deleted row — resuming from the nearest surviving position is usually right, and silently returning nothing is not.

When offset is still acceptable: small, stable datasets where users expect numbered pages — an admin table of 200 rows. Then the ability to jump to page 7 is worth more than consistency under insertion.

Key takeaways

  • Name resources, not actions. HTTP methods supply the verbs.
  • Status codes carry meaning: 4xx means the client must change something, 5xx means retry. 401 is "log in"; 403 is "stop".
  • Return structured errors with a stable machine-readable code, in an identical shape everywhere.
  • Idempotency keys let clients retry safely after a lost response — essential for payments.
  • Additive changes are safe; renames and redefinitions are not. Version before you have clients.
  • Default to REST; choose GraphQL when diverse clients need genuinely different data and you can control query cost.
  • Paginate every list from day one, because adding it later breaks clients.

Further reading

  • RFC 9110, HTTP Semantics — methods, status codes and their intended meanings.
  • RFC 9457, Problem Details for HTTP APIs — a standard error body shape.
  • Roy Fielding, "Architectural Styles and the Design of Network-based Software Architectures" (doctoral dissertation, 2000), chapter 5.
  • Mark Nottingham, "Web API Design" and RFC 8594, The Sunset HTTP Header Field — deprecating an endpoint without breaking clients.
  • Rick Strahl and others, "Pagination in APIs" — the offset-versus-cursor comparison as practiced.

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonData Modeling
Lessons in this track