Building APIs

Lesson 1 of 5

REST Design & Resources

"A request method is considered 'idempotent' if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request." — RFC 9110, HTTP Semantics, §9.2.2

Everything so far has been about a framework. This part is about the API itself, and the distinction matters because the decisions in this chapter survive the framework. Move from Django to FastAPI and your views are rewritten; your URLs, methods, status codes and error shapes are still the same contract, still consumed by the same clients, still wrong in the same ways if you got them wrong.

"REST" is an unhelpful word here, and it is worth being blunt about why. Roy Fielding's 2000 dissertation described an architectural style with a set of constraints, one of which — hypermedia as the engine of application state — is so rarely implemented that Fielding himself wrote a post in 2008 complaining that people were calling things REST that were not. What the industry means by "REST API" is resource-oriented HTTP with JSON bodies: URLs that name things, methods that say what to do to them, status codes that describe what happened. That is a genuinely good design, it is what interviewers mean when they say REST, and this chapter is about doing it well rather than about defending the terminology.

The core idea is a division of labour. The URL identifies a thing; the method says what to do to it. Every design question in this chapter is a consequence: what counts as a thing, what to do when an operation is not obviously a verb applied to a noun, and what each method and status code commits you to — particularly under retry, which is where a design that reads well starts corrupting data.

The big question

If a client sends a request and the connection drops before the response arrives, it does not know whether the operation happened. It must either retry and risk doing it twice, or not retry and risk not doing it at all. Which of those is safe is decided entirely by choices you make when designing the URL and picking the method — so which choices, and how do you make "charge this card" retryable?

Nouns, not verbs

The first test of a resource-oriented API is mechanical enough to automate: are the paths made of nouns? An API whose URLs contain verbs has moved the operation from the method into the path, which means HTTP's method semantics no longer describe what the request does — and every downstream assumption about caching, retrying and proxying breaks with it.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

The linter is crude and the point is not the linter. It is that POST /getOrder and GET /orders/7 are not stylistic variants. In the second, a proxy can cache the response, a crawler can follow the link safely, a client library can retry on a timeout without asking you, and a reader can tell what happens without documentation. In the first, all of that information has been moved into a string the infrastructure cannot interpret.

The one thing the linter cannot check is whether your nouns are the right nouns, and that is the actual design work. Resources should be things your clients reason about, not your tables. A /subscriptions resource that is stored across four tables is fine; four endpoints exposing those tables is a database schema with a URL scheme bolted on, and it will change whenever your storage does.

Safe, idempotent, neither

The method is a promise about consequences. RFC 9110 defines two properties, and everything about retry behaviour follows from them: a safe method does not modify state, and an idempotent method has the same effect whether applied once or many times. Safe implies idempotent; the reverse does not hold.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

The table is worth memorising because interviewers ask for it and because it decides your retry policy. GET changed nothing. PUT and DELETE reached the same state twice — that is what idempotent means, and note that DELETE returning 404 the second time is still idempotent, because idempotence is about state, not about the response. POST created two orders, which is the entire problem.

PATCH is the interesting row. It is not idempotent in general, and whether your PATCH is depends on a decision you make in the request body. {"items": 9} sets a value and can be repeated safely. {"items_delta": 1} describes a change and cannot. Both are legitimate; only one can be retried by a client that timed out, and clients will retry whichever you ship.

Everyday analogy

A filing cabinet versus an assistant who takes instructions. In the filing-cabinet model, every document has a location, and the operations are the small set you can perform on any drawer: look at what is there, put something there, take it away. Anyone who understands cabinets can use yours without training, and if you tell them "look at drawer 7" twice, nothing bad happens. In the instruction model, you tell the assistant what you want done — "reconcile the March invoices" — and the meaning of every instruction is private to your office. It is more expressive; it is also completely opaque to anyone outside, it cannot be checked mechanically, and if the phone cuts out halfway through you have no idea whether the reconciliation ran. Resource-oriented design is the cabinet. It is deliberately less expressive than RPC, and it trades that expressiveness for a set of guarantees that every intermediary in the world already understands.

Making POST retryable

A payment API cannot use PUT, because the client does not choose the payment's identifier and the server assigns it. So the operation is POST, which is not idempotent, and the client that times out is stuck between charging twice and not charging at all. The industry answer — Stripe's, and by now nearly everyone's — is an idempotency key: the client generates a unique identifier for the attempt, and the server remembers what it did with it.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Three properties make this work, and skipping any of them produces a subtly broken version. The key is chosen by the client, before the first attempt, and reused on retries — a server-generated key cannot help, because the client that timed out never received one. The stored record includes the response, not just a flag, so the retry gets the original result rather than a conflict. And the stored record includes a fingerprint of the request, so reusing a key with a different body is an error rather than a lie.

What this really does is give POST the property PUT has for free. PUT /orders/2026-08-13-ada-001 is idempotent because the client picked the identifier; an idempotency key is the same idea, decoupled from the URL so the server can still assign the identifier.

A little history

The confusion around the word "REST" has a specific origin. Fielding's dissertation described a style whose constraints — client-server, stateless, cacheable, layered, uniform interface — were an abstraction of what already made the web work, written to explain HTTP rather than to specify APIs. What spread in 2006–2010 was something narrower, driven by the practical collapse of SOAP: teams wanted JSON over HTTP without WSDL, and "RESTful" became the label for it. In 2008 Fielding published a post titled "REST APIs must be hypertext-driven", which reads as visibly exasperated and made no difference at all. Meanwhile Leonard Richardson's maturity model, presented at QCon in 2008 and popularised by Martin Fowler in 2010, gave the industry a more useful ladder: level 0 is one URL and one method (a tunnel), level 1 introduces resources, level 2 uses HTTP methods and status codes as intended, level 3 adds hypermedia controls. Nearly every API described as REST today is level 2, and the honest position is that level 2 captures most of the value — the uniform interface, the cacheability, the retry semantics — while level 3's promise of clients discovering the API at runtime never materialised, because client authors read documentation and generate code from schemas instead. That is not a failure of the idea so much as evidence about how software is actually built.

Status codes are commitments

A status code is not decoration. It is the part of the response that intermediaries and generated clients act on without reading your body, and choosing the wrong one produces retries you did not want, caching you did not want, or an outage that looks like a client bug.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Two rows deserve individual attention. The pair of 404s is a deliberate design decision: if returning 403 for someone else's order tells an attacker that the order exists, then 404 is the correct response even though you know perfectly well where it is. Enumeration attacks are built out of exactly this difference, and the choice belongs in your access-control layer rather than in each endpoint.

The 200 with an error body inside is the most common failure in APIs written by people who have not had to consume one. It defeats every generic mechanism at once — monitoring counts it as a success, retry logic never fires, and every client must learn a body-shaped convention that no tool understands. If it failed, say so in the status line.

When the operation is not a noun

Eventually something does not fit. Cancelling an order, resending an invoice, refunding a payment, retrying a job — these are verbs, and the resource-oriented answer of "PATCH the status field" is sometimes right and sometimes a bad lie about what happened.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

The distinction is not purity. It is whether the action has an existence of its own. A cancellation has a reason, an actor, a refund that may fail independently, and a state that outlives the request — so it is a resource, and modelling it as a field on the order means there is nowhere for any of that to live. Marking a to-do item done has none of that, and PATCH /todos/9 {"done": true} is correct and complete.

When the action really is a verb with no state — POST /orders/7/resend-receipt — take the pragmatic path and use it. It is level-2 honest, every reviewer understands it, and inventing /orders/7/receipt-transmissions to avoid a hyphenated verb is the kind of purity that makes people quietly build a second, undocumented API.

The engineer's notebook

Conventions worth settling once, in writing, before the first endpoint. Plural collections — /orders, /orders/7 — with singletons only for genuine one-of-a-kind resources like /me. Nesting one level deep at most: /customers/3/orders is useful because it scopes and authorises in one step, but /customers/3/orders/7/items/2/discounts should be /discounts/44, because a resource with an identifier does not need its ancestry to be found. Hyphens in multi-word segments, lowercase, no trailing slash — and pick one and enforce it with a redirect, since /orders and /orders/ being different endpoints is a class of bug that survives for years. Snake case in JSON bodies if your clients are Python, camel case if they are TypeScript; what matters is that it is uniform, because a body mixing both is a permanent tax on everyone. Identifiers: sequential integers leak your volume and invite enumeration, so use UUIDs or ULIDs in anything customer-facing — ULIDs sort by creation time, which you will want for pagination. And never reuse an identifier after a delete, however tempting the empty slot looks.

What sequential identifiers tell an attacker

The advice to avoid sequential identifiers usually arrives as a rule with no arithmetic behind it, which makes it easy to dismiss. The arithmetic is worth seeing, and most of it does not even need the famous version — an identifier assigned in sequence is a count, handed to whoever asked for it.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Two separate leaks from one design choice. A single identifier is a running total, and two of them a fortnight apart give a growth rate — competitive intelligence, free, from an integer nobody thought of as data. The German tank estimator is the version for when you cannot place an order yourself and only see scattered identifiers in public receipts or referral links; it is the same leak, working harder. Separately, sequential identifiers make enumeration cheap, and if your authorisation layer distinguishes 403 from 404, the attacker learns which identifiers are real without needing access to any of them.

In the real world

The most instructive REST-design failures are not the ugly APIs, they are the ones that were fine for two years and then became load-bearing in a way nobody planned. A common shape: an internal API models everything as PATCH /orders/{id} with a status field, because early on the statuses were simple and the endpoint was called by one internal service. Then finance needs to know who cancelled an order and why. Then support needs to reverse a cancellation. Then a partner integration needs to cancel orders and gets the same PATCH, so now an external system can set status to any value in the enum, including fulfilled. The fix is a cancellation resource with its own permissions, which means a new endpoint, a migration to backfill cancellation records from an audit log that was never designed to be read that way, and a deprecation window for the PATCH that lasts eighteen months because one partner will not move. None of the individual decisions were unreasonable. The compounding one was treating a state transition as a field assignment, and the tell was there from the beginning: the moment somebody asks "who did this and why", the action has become a thing, and things want to be resources.

Common Mistakes

  1. Verbs in paths. POST /createOrder moves the operation out of the method, so no intermediary can tell what the request does. Every retry, caching and instrumentation decision then has to be made by hand.

  2. Exposing tables as resources. Resources should be what clients reason about. One endpoint per table couples your public contract to your storage, so a normalisation you do for performance becomes a breaking change for someone else.

  3. Assuming PATCH is idempotent. It is only as idempotent as your body format. {"items": 9} can be retried; {"items_delta": 1} cannot, and clients will retry both.

  4. Using POST for something the client can name. If the client chooses the identifier, PUT gives you idempotency for free. Reach for an idempotency key when the server must assign the identifier — not as a substitute for thinking about which method fits.

  5. Storing only a flag against an idempotency key. The retry needs the original response, not a "seen this" marker. Store the status and body, and a fingerprint of the request so a key reused with a different body is an error.

  6. Returning 200 with an error inside. Monitoring counts it as success, retries never fire, caches misbehave, and every client must parse your body to discover a failure that the status line exists to report.

  7. Confusing 400 with 422. 400 is a malformed request — the JSON did not parse. 422 is a well-formed request that is semantically wrong. Clients act on the difference: one is a bug in their serialiser, the other is a bug in their data.

  8. Leaking existence through 403. If a user may not know a resource exists, 404 is the correct answer even when you know it does. Decide this centrally; it is not a per-endpoint judgement call.

  9. Nesting more than one level. /customers/3/orders scopes and authorises usefully. /customers/3/orders/7/items/2 does not — item 2 has an identifier, so it can be addressed directly, and the deep path just creates a second URL for the same thing.

  10. Sequential integer identifiers in public URLs. They disclose volume and growth to anyone who can place two orders, and they make enumeration trivial. ULIDs cost 26 characters and still sort by creation time.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Junior
What makes an API RESTful, and what do GET, POST, PUT, PATCH and DELETE mean?
Model answer — The core idea is that the URL identifies a thing and the method says what to do to it, so the paths are nouns and the verbs come from HTTP. GET reads a resource and must not change anything. POST creates a new one in a collection, and the server assigns its identifier. PUT replaces a resource at a known URL. PATCH updates part of one. DELETE removes it. The useful property is that GET is safe, meaning it does not modify state, and PUT and DELETE are idempotent, meaning sending them twice leaves the same state as sending them once — so a client that times out can safely retry them. POST is neither, which is why retrying a failed POST can create two orders. Status codes matter for the same reason: 201 for a created resource, 204 for a successful delete with no body, 404 for something that does not exist. Strictly, most APIs called REST are resource-oriented HTTP rather than everything in Fielding's original definition, since hypermedia is rarely implemented, but that is the design people mean.
Mid
A client's request times out. How do you make it safe for them to retry a payment?
Model answer — The client cannot know whether the charge happened, so the operation has to be idempotent, and POST is not. The standard mechanism is an idempotency key: the client generates a unique key for the attempt before the first request, sends it in a header, and reuses the same key on every retry. The server stores what it did with that key — the status code and the response body, not just a marker — and on seeing the key again it replays the stored response instead of charging again. Three details make or break it. The key must be generated by the client, because a server-generated one never reaches a client that timed out. The stored record must include the response, otherwise the retry gets a conflict rather than the result it needed. And it should include a fingerprint of the request body, so that reusing a key with different parameters is rejected as an error rather than silently returning a response that describes a different charge. I would also give the records a retention window — a day or so — and be careful about the race where two identical requests arrive concurrently, which needs a uniqueness constraint on the key at the database level rather than a check-then-insert. Conceptually it gives POST the property PUT has for free, which is that the client names the operation.
Senior
Walk me through designing the API for cancelling an order.
Model answer — I would start by asking whether cancellation is a field or a thing, because that decision is hard to reverse. If it is genuinely a boolean with no history — no reason, no actor, no downstream effect — then PATCH /orders/{id} with a status field is honest and I would not invent ceremony. But cancellation usually is not that. It has a reason, an actor, possibly a refund that can fail independently of the cancellation succeeding, and finance will eventually ask who did it. All of that means it has state and a lifecycle of its own, so I would model it as POST /orders/{id}/cancellations returning 201 with a cancellation resource that has its own identifier and is addressable afterwards. That buys several things a field update cannot. Attempting to cancel twice can return 409 with the existing cancellation rather than silently overwriting. The refund can move through its own states without polluting the order's status enum. Permissions are separable, so a partner can be allowed to cancel without being allowed to set the order's status to anything else — which is the concrete failure mode of the PATCH design, since it exposes the whole enum to anyone who can call it. And the audit trail is a table rather than a reconstruction from logs. I would still expose the derived state on the order for convenience, because clients want to know an order is cancelled without a second request, but the write path goes through the resource. The general test I apply is: the moment someone asks who did this and why, the action has become a thing, and things want to be resources.
Company lens — how the same topic gets asked
micro1Concept: why paths are nouns and the methods are the verbs.
GoogleDepth: safe versus idempotent, and what each permits a client to retry.
MetaDesign: modelling an action that is not obviously a noun.
AmazonReality: sequential ids that disclosed order volume to a competitor.
Hands-on

Build the three pieces this chapter turns on. route must dispatch a method and path to the right handler and produce the right status code — including 201 for a creation, 204 for a delete, 404 for a missing resource and 409 for a conflicting one. with_idempotency must wrap a POST handler so a repeated key replays the original response and a reused key with a different body is refused. is_idempotent must answer the question the whole chapter is about. The checklist reports each requirement as PASS, FAIL or ERR; run it before writing anything and watch the lines flip.

Python — Try it Yourself
Loading editor…
Output
Click Run to execute the code.

Summary

  • The URL identifies a thing; the method says what to do to it. Every other rule in resource design is a consequence, and an API with verbs in its paths has given up the guarantees HTTP provides for free.

  • Resources are what clients reason about, not what your tables are. Exposing one endpoint per table makes your storage schema a public contract, so normalising for performance becomes someone else's breaking change.

  • Safe means no state change; idempotent means repeating is harmless. GET is both, PUT and DELETE are idempotent, POST is neither, and PATCH is whatever your body format makes it.

  • An idempotency key gives POST what PUT has for free. The client generates it before the first attempt; the server stores the response and a fingerprint of the request, so a retry replays and a reused key with a different body is refused.

  • Status codes are acted on by machines. Generic clients retry 429 and 503, cache 200 and 404, and raise 4xx permanently — so 500 where 422 belonged produces retries that can never succeed.

  • 200 with an error inside defeats every generic mechanism at once. Monitoring, retries and caching all read the status line; if it failed, say so there.

  • 404 over 403 when existence itself is confidential. Whether a user may learn that a resource exists is an access-control decision, made centrally rather than per endpoint.

  • An action becomes a resource once it has state, an actor or a lifecycle. A cancellation with a reason, an actor and a refund is a thing; a boolean flip with no history is a field, and PATCH is the honest answer for it.

  • Nest one level at most. A resource with an identifier can be addressed directly; a deep path is a second URL for the same thing and doubles what you have to keep working.

  • Sequential identifiers disclose volume and invite enumeration. Two observations, weeks apart, estimate your growth rate; ULIDs cost 26 characters and still sort by creation time.

This chapter settled what the resources are and how they are addressed. The next chapter, Serialization, is about what actually goes in the body: where the translation between database rows and JSON belongs, why Django REST Framework's serializers and Pydantic's models solve the same problem in visibly different ways, what happens to decimals, datetimes and money on the way out, and how a serializer quietly becomes the slowest part of an endpoint.

Editorial reflection

What strikes me about resource design is how much of it is really about giving up expressiveness on purpose. RPC lets you say anything: name a procedure, give it arguments, get a result. Resource-oriented design says you may only use five verbs, and everything you want to express must be bent into the shape of things and operations on things. That is a genuine loss, and the bending is sometimes awkward — the cancellation example is a case where the awkwardness is productive and forces a better model, but there are others where it plainly is not, and the honest response is a hyphenated verb rather than an invented noun. The compensation for the loss is that a constrained vocabulary is one that other software can understand. A cache, a proxy, a retry policy, a client generator, a rate limiter and a monitoring dashboard can all do something useful with GET /orders/7 without any knowledge of your domain, and none of them can do anything at all with POST /doTheThing. That is the same trade the whole industry keeps making in different clothes — static types, declarative configuration, SQL over hand-rolled traversal — where you accept a smaller space of things you can say in exchange for a machine being able to reason about what you said. It is worth noticing when you are on the other side of that trade, refusing the constraint because your case is special. Sometimes it genuinely is. Usually the constraint knew something you did not.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.