Index Ai Academy
Phase 8 · APIs · Building APIs

Designing a REST API

Resources, methods, status codes, errors and pagination done well.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Names a developer can guess
  2. Status codes, precisely
  3. One error shape, everywhere
  4. Filtering, sorting and pagination
  5. An opaque cursor
  6. 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 /GiftCards or /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_103 or ord_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:

methodsafe (changes nothing)?idempotent (repeating is harmless)?
GETyesyes
PUTnoyes: the second PUT sets the same values again
DELETEnoyes: the item is gone either way
PATCHnousually, for "set" changes; not for "add 1"
POSTnono: 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.

Quick check
What does this print?
balance = 100
def set_balance(v):
    global balance
    balance = v
set_balance(50)
set_balance(50)
print(balance)
  1. 150
  2. 50
  3. 100
  4. 0
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.

Quick check
A client sends DELETE /orders/ord_5, the network drops the response, and the client sends it again. What should the API do?
  1. Treat it as harmless: the order stays deleted (answer 204, or 404 if you report that it's already gone)
  2. Delete the next order instead
  3. Restore the order and delete it again
  4. Answer 500
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:

codeuse it when
200 OKa read or update worked
201 Createda POST created something; add a Location header pointing at it
202 Acceptedthe work was queued and will finish later
204 No Contentit worked and there's nothing to send back (often DELETE)
400 Bad Requestthe request is malformed: bad JSON, missing a required field
401 Unauthorizedno credentials, or credentials that don't work
403 Forbiddenthe credentials work, but aren't allowed to do this
404 Not Foundno such resource (also used to avoid revealing one exists)
409 Conflictit clashes with current state: duplicate email, stale version
422 Unprocessable Contentwell-formed, but the values fail the rules
429 Too Many Requestsrate limited; say when to retry with Retry-After
500 Internal Server Errora bug on the server
503 Service Unavailableoverloaded 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.

Try it now

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.

Quick check
A valid API key tries to delete another company's campaign. Which status fits best?
  1. 401 Unauthorized
  2. 400 Bad Request
  3. 403 Forbidden, or 404 to avoid revealing the campaign exists
  4. 500 Internal Server Error
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
It runs right here in your browser, with nothing to install.

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.

Worked example

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))
In the lesson you can change this and run it.

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.

Quick check
Which change needs a new API version?
  1. Adding a new endpoint
  2. Removing a response field that clients read
  3. Fixing a typo in the documentation
  4. Adding a new optional field to a response
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

Create a free account

Try a quiz question

Which method is NOT idempotent?

  1. POST
  2. PUT
  3. DELETE
  4. GET

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson