Designing a REST API
Resources, methods, status codes, errors and pagination done well.
Create a free account Try part of it here
Why it matters
An API is a user interface for programmers, and now for AI agents too. A well-designed one can be used correctly from a glance at one example. A badly designed one causes bugs in every program that calls it. Those bugs are hard to fix later, since once people depend on your API, every change risks breaking them. The habits here are the ones behind APIs people praise, like Stripe's and Anthropic's.
What you'll be able to do
- Name resources and routes so developers can guess them
- Pick status codes precisely, and return errors in one consistent shape
- Explain safe and idempotent methods, and why retries depend on them
- Implement cursor pagination that stays correct while data changes
What it covers
- Names a developer can guess
- Status codes, precisely
- One error shape, everywhere
- Filtering, sorting and pagination
- An opaque cursor
- Versioning
Names a developer can guess
In a well-run supermarket, you can find the pasta in a store you've never visited. The signs follow rules you already know. A good API works the same way, so once someone has seen one address, they can guess the rest:
- Plural nouns for collections:
/campaigns,/orders. One item lives under its collection:/campaigns/cmp_103. - Lowercase, with hyphens between words:
/gift-cards, not/GiftCardsor/gift_cards. - Nest one level, at most, for true ownership:
/customers/c_42/orders. Deeper nesting gets unwieldy, so use filters instead:/orders?customer=c_42. - Ids as opaque strings with a prefix, such as
cmp_103orord_9xK2. A prefix tells a person what kind of thing an id is at a glance. Opaque means nobody should read meaning into the rest, which leaves you free to change how ids are made later. - Actions that don't fit a method become a sub-resource:
POST /campaigns/cmp_103/pause.
Safe and idempotent methods
Pressing the elevator button twice doesn't call two elevators. Putting an item in your cart twice can mean you buy two. The first is idempotent, a long word for "repeating it is harmless". The second isn't.
A related idea is safe, meaning "changes nothing at all", like reading a menu. Together, these two properties decide what a client may repeat:
| method | safe (changes nothing)? | idempotent (repeating is harmless)? |
|---|---|---|
| GET | yes | yes |
| PUT | no | yes: the second PUT sets the same values again |
| DELETE | no | yes: the item is gone either way |
| PATCH | no | usually, for "set" changes; not for "add 1" |
| POST | no | no: two POSTs can create two orders |
This matters because networks fail. A request can succeed on the server, and then its response gets lost on the way back. The client can't tell which happened, so it retries. Retrying an idempotent request is always fine. Retrying a POST can charge a customer twice, unless the API supports idempotency keys, covered in a later lesson.
balance = 100 def set_balance(v): global balance balance = v set_balance(50) set_balance(50) print(balance)
Show the answer
50
The first call sets balance to 50, and the second sets it to 50 again, so it prints 50. Doing it twice has the same effect as doing it once. That's what idempotent means, and it's why PUT can be retried.
DELETE /orders/ord_5, the network drops the response, and the client sends it again. What should the API do?Show the answer
Treat it as harmless: the order stays deleted (answer 204, or 404 if you report that it's already gone)
DELETE is idempotent: after one or ten calls the order is gone. Either answer is fine, as long as nothing else changes.
Status codes, precisely
Think of the status code as a stamp on a returned letter. The first digit says whose problem it is:
- 2xx: it worked.
- 4xx: the client must change something. Sending the same thing again won't help.
- 5xx: the server failed, and a retry might work.
These codes cover nearly every case:
| code | use it when |
|---|---|
| 200 OK | a read or update worked |
| 201 Created | a POST created something; add a Location header pointing at it |
| 202 Accepted | the work was queued and will finish later |
| 204 No Content | it worked and there's nothing to send back (often DELETE) |
| 400 Bad Request | the request is malformed: bad JSON, missing a required field |
| 401 Unauthorized | no credentials, or credentials that don't work |
| 403 Forbidden | the credentials work, but aren't allowed to do this |
| 404 Not Found | no such resource (also used to avoid revealing one exists) |
| 409 Conflict | it clashes with current state: duplicate email, stale version |
| 422 Unprocessable Content | well-formed, but the values fail the rules |
| 429 Too Many Requests | rate limited; say when to retry with Retry-After |
| 500 Internal Server Error | a bug on the server |
| 503 Service Unavailable | overloaded or down for maintenance; retry later |
For a request that is valid JSON but whose values break the rules, such as a negative budget, 422 Unprocessable Content fits best, though some APIs use 400 for all input errors. Never answer 200 with {"error": ...} inside. Clients, monitoring and retry logic all read the status code first, so they'd count the failure as a success.
Developers and agents use these codes to decide what to do next.
One error shape, everywhere
If every department of a company sent complaints on a different form, nobody could process them. So pick one error format and use it for every endpoint. Here's Anthropic's:
{"type": "error", "error": {"type": "not_found_error", "message": "model: claude-opus-9 not found"}}
A machine-readable type lets code branch on it, and a human message says what to fix. Many APIs add a param naming the bad field. There's also an internet standard, problem details (RFC 9457), with type, title, status and detail fields. Which one you choose matters less than using it everywhere.
Show the answer
403 Forbidden, or 404 to avoid revealing the campaign exists
401 means the credentials themselves failed. Here they're valid but not allowed this action: 403. Many APIs return 404 so outsiders can't probe which ids exist.
Filtering, sorting and pagination
Lists need options, passed as query parameters: GET /orders?status=paid&sort=-created&limit=20. A leading - for descending order is a common convention.
Never return an unbounded list. One day that list has a million rows. Pagination returns a page at a time, and there are two main ways.
Offset pagination (?offset=40&limit=20) means "skip 40, then give me 20". It's like saying "start at line 40". That's simple, but it breaks when data changes between pages. If a new order arrives while you read, every line shifts down by one, and one order appears on two pages:
orders = [f"order {n}" for n in range(10, 0, -1)] # newest first page1 = orders[0:4] orders.insert(0, "order 11") # a new order arrives page2 = orders[4:8] print("page 1:", page1) print("page 2:", page2) # "order 7" appears twice
Large offsets are also slow, because the database still counts past every skipped row.
Cursor pagination is a bookmark. A bookmark marks where you stopped, however many pages get added before it. Each page of results ends with a next_cursor: a token that says where you stopped, such as the sort key of the last item. The next request asks for items after that point. New items can't shift anything, and the database can jump straight there with an index. The response looks like this:
{"data": [ ... ], "has_more": true, "next_cursor": "eyJhZnRlciI6IFsxNzMwLCAib3JkXzkiXX0"}
Make the cursor opaque: encode it, so clients pass it back without building or editing it. Then you're free to change what's inside.
An opaque cursor
A cursor is often JSON encoded with URL-safe base64. Base64 turns any bytes into letters, digits, - and _, which are safe in a URL.
import base64, json def encode_cursor(position): raw = json.dumps({"after": position}).encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") def decode_cursor(cursor): padded = cursor + "=" * (-len(cursor) % 4) # put back the padding we removed return json.loads(base64.urlsafe_b64decode(padded))["after"] c = encode_cursor([1730, "ord_9"]) print(c) print(decode_cursor(c))
Base64 isn't encryption: anyone can decode it, so never put secrets in a cursor. It only signals "don't build this yourself". The = padding is stripped to keep URLs tidy and added back before decoding. Sorting by a time and the id matters. Two items can share a timestamp, and the id breaks the tie so no item is ever skipped.
Versioning
Adding a dish to a menu upsets nobody. Taking away someone's usual order does. APIs work the same way.
Some changes are additive: a new endpoint, a new optional field, a new field in responses. Well-behaved clients ignore fields they don't know, so additive changes don't break anyone. Removing or renaming a field, changing a type or tightening a rule is breaking.
Breaking changes need a new version. There are two common styles:
- A version in the URL:
/v1/campaigns,/v2/campaigns. It's easy to see and to route. - A version in a header, often a date: the Claude API's
anthropic-version: 2026-10-21. Each client keeps the behavior of the date it pinned, until it opts into a newer one.
Either way, keep old versions running long enough for clients to move, and announce removals well in advance.
Show the answer
Removing a response field that clients read
Removing or renaming something clients use breaks them. Additions are usually safe within a version.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Which method is NOT idempotent?