Skip to content
BytePatterns

REST API Design: Resources, Methods, Status Codes, Idempotency

8 min readBytePatterns

REST API design for interviews: nouns in paths, verbs as methods, 201 with Location, safe and idempotent methods, retries with idempotency keys, and a toy API.

Almost every system design interview has a moment where the interviewer says "let's define the API". The candidate who writes POST /createOrder and GET /getOrderById?id=7 has not done anything wrong enough to fail, but has signalled that they have not thought about what the HTTP method is for. REST gives a small set of conventions: the path names a thing, the method says what to do to it, the status code reports what happened. Their real payoff is not style. It is that clients, caches and retries can behave correctly without knowing anything about your application.

The problem it solves

An API is a contract with programs you will never see: mobile apps on old versions, partner scripts, proxies, browser caches. They all need to answer questions like "is it safe to retry this after a timeout?" and "may I cache this response?" without reading your documentation. REST answers them through uniform rules:

  • Resources are nouns with stable addresses: /articles is a collection, /articles/91 is one member.
  • Methods are the verbs, and each has defined properties. GET is safe (it changes nothing) and cacheable. PUT and DELETE are idempotent: doing them twice leaves the server in the same state as doing them once. POST is neither.
  • Status codes carry the outcome in a form every client understands: 2xx success, 4xx the client's fault, 5xx the server's.

The intuition

Idempotency is the property that matters most in distributed systems, because networks fail in the worst possible place: after the server did the work, before the client heard about it. The client cannot tell a lost request from a lost response, so it retries.

  • A retried PUT /articles/91 with the same body replaces the article with the same content. Harmless.
  • A retried DELETE /articles/91 finds nothing to delete. The status may change from 204 to 404, but the state is the same, and that is what idempotency is about.
  • A retried POST /articles creates a second article. For orders or payments, that is a double charge.

The standard fix for POST is an idempotency key: the client generates a unique key per logical operation and sends it in a header; the server stores the first response under that key and replays it for any retry. As of September 2026, an Idempotency-Key header is being standardised at the IETF and is already common in payment APIs.

The other conventions follow from "the path is a noun": POST /articles answers 201 Created with a Location header pointing at the new resource, a method the resource does not support answers 405 with an Allow header, and actions that are verbs by nature, like a refund, become either a sub-resource (POST /orders/7/refunds) or a clearly named exception.

Watch it run

The animation fills in the lesson's five-line request log one line at a time. REST models a system as addressable resources: the path names a noun and the method is the verb. POST to the collection creates a new article, and the server answers 201. 201 alone is not enough: a Location header says where the new thing lives, /articles/91. That address is now stable; GET reads it, changes nothing, and can be cached. PUT replaces the article at that same address and answers 200. The client times out and retries the same PUT, and the world ends up in exactly the same state. DELETE removes it, and since there is nothing to send back, 204 No Content. Retry that too: still deleted, still the same state, idempotent as well. The resource really is gone, so reading it now is a 404. POST is the exception: send it twice and you get two articles, not one. And /getUserById?id=7 welds the verb onto the address, so the address stops being stable. The last frame admits that some operations are verbs first, a refund or a publish, and that consistency beats purity: pick a convention, version it, and hold it.

Designing a REST API

Step 1 of 12

REST models a system as addressable resources. The path names a noun; the method is the verb.

The same interactive animation as the lesson — step through it with the controls.

The code

A toy model of the resource: no network, just a handler that returns (status, headers, body) the way a web framework's route would. It replays the lesson's log, plus a method the collection does not allow:

import json

class ArticlesAPI:
    """Toy model of a REST resource: no network, just the rules. Returns
    (status, headers, body) the way an HTTP handler would."""
    def __init__(self):
        self.articles, self.next_id, self.seen_keys = {}, 91, {}

    def handle(self, method, path, body=None, headers=None):
        headers = headers or {}
        parts = path.strip("/").split("/")
        if parts[0] != "articles" or len(parts) > 2:
            return 404, {}, None
        if len(parts) == 1:                          # the collection: /articles
            if method == "GET":
                return 200, {}, [dict(id=i, **a) for i, a in sorted(self.articles.items())]
            if method == "POST":
                key = headers.get("Idempotency-Key")
                if key in self.seen_keys:            # a retried POST: replay the first answer
                    return self.seen_keys[key]
                new_id, self.next_id = self.next_id, self.next_id + 1
                self.articles[new_id] = dict(body)
                answer = (201, {"Location": f"/articles/{new_id}"}, dict(id=new_id, **body))
                if key:
                    self.seen_keys[key] = answer
                return answer
            return 405, {"Allow": "GET, POST"}, None
        if not parts[1].isdigit():
            return 404, {}, None
        item = int(parts[1])                         # one member: /articles/91
        if method == "GET":
            if item not in self.articles:
                return 404, {}, None
            return 200, {}, dict(id=item, **self.articles[item])
        if method == "PUT":                          # replace the whole representation
            created = item not in self.articles
            self.articles[item] = dict(body)
            return (201 if created else 200), {}, dict(id=item, **body)
        if method == "DELETE":
            return (204 if self.articles.pop(item, None) is not None else 404), {}, None
        return 405, {"Allow": "GET, PUT, DELETE"}, None

api = ArticlesAPI()
log = [("POST", "/articles", {"title": "Queues"}), ("GET", "/articles/91", None),
       ("PUT", "/articles/91", {"title": "Queues, revised"}), ("PUT", "/articles/91", {"title": "Queues, revised"}),
       ("DELETE", "/articles/91", None), ("DELETE", "/articles/91", None), ("GET", "/articles/91", None),
       ("PATCH", "/articles", None)]
for method, path, body in log:
    status, hdrs, _ = api.handle(method, path, body)
    print(method, path, status, *([json.dumps(hdrs)] if hdrs else []))
# POST /articles 201 {"Location": "/articles/91"}
# GET /articles/91 200
# PUT /articles/91 200
# PUT /articles/91 200
# DELETE /articles/91 204
# DELETE /articles/91 404
# GET /articles/91 404
# PATCH /articles 405 {"Allow": "GET, POST"}

The second DELETE answers 404, yet the state is unchanged, which is all idempotency promises. A retried POST, without and with a key, then 3,000 seeded random request sequences in which one request is retried. The brute force replays the retry and compares the final state with a run that sent it once:

api = ArticlesAPI()
for _ in range(2):                                  # the client timed out and sent it again
    api.handle("POST", "/articles", {"title": "Heaps"})
print(len(api.articles))                            # 2  two articles: POST is not idempotent
for _ in range(2):
    api.handle("POST", "/articles", {"title": "Tries"}, {"Idempotency-Key": "c1f3"})
print(len(api.articles))                            # 3  the key made the retry harmless

import copy
import random

def random_request(rng):
    method = rng.choice(["GET", "PUT", "DELETE", "POST", "POST"])
    target = "/articles" if method == "POST" else f"/articles/{rng.randint(91, 95)}"
    body = {"title": rng.choice("abc")}
    headers = {"Idempotency-Key": str(rng.randint(1, 3))} if rng.random() < 0.5 else {}
    return method, target, body, headers

rng = random.Random(30)
ok, post_dupes = True, 0
for _ in range(3_000):
    reqs = [random_request(rng) for _ in range(rng.randint(1, 8))]
    once, twice = ArticlesAPI(), ArticlesAPI()
    k = rng.randrange(len(reqs))                    # this one is retried
    for i, (m, p, b, h) in enumerate(reqs):
        before = copy.deepcopy(once.articles)
        once.handle(m, p, b, h)
        if m == "GET":
            ok &= once.articles == before           # safe: reading changes nothing
        twice.handle(m, p, b, h)
        if i == k:
            twice.handle(m, p, b, h)                # brute force: replay it and compare
    m, _, _, h = reqs[k]
    if m in ("GET", "PUT", "DELETE") or "Idempotency-Key" in h:
        ok &= once.articles == twice.articles       # idempotent: the retry left no trace
    else:
        post_dupes += once.articles != twice.articles
print(ok, post_dupes > 0)                           # True True

The complexity

  • Per request: one lookup by id, O(1) in a hash map, O(log n) in an indexed table.
  • Idempotency keys: one stored response per key, kept for a retention window (commonly a day) and then expired, so storage is proportional to write traffic in that window.
  • Collections: never return all of them; paginate with a cursor, the API face of keyset pagination, which stays fast on deep pages.

Where it goes wrong

  • Verbs in paths. /createUser, /deleteUser duplicate what the method already says and break caching and tooling that rely on method semantics.
  • 200 for everything. An error body with a 200 status is invisible to retries, monitoring and caches.
  • Non-idempotent PUT. A PUT that increments a counter or appends to a list breaks every client that retries it; that is a POST.
  • Retrying POST blindly. Without an idempotency key, a client-side retry policy turns timeouts into duplicate orders.
  • No versioning plan. Put a version in the path or a header before the first external client exists.

When it shows up in interviews

In the API step of almost every system design question, from a URL shortener to a chat app, where the follow-ups are pagination, rate limiting and what happens on retry. Idempotency returns in message queues, where at-least-once delivery forces the same design on consumers.

How to say it in an interview

"I model the API around resources: /orders and /orders/{id}, with the method as the verb. Creating an order is POST /orders, which answers 201 with a Location header. GET is safe and cacheable; PUT and DELETE are idempotent, so clients can retry them after a timeout. POST isn't, so for creating orders I'd require an idempotency key and store the first response per key. Errors use real status codes, collections are cursor-paginated, and the API is versioned from day one. For operations that are verbs by nature, like a refund, I'd use a sub-resource such as POST /orders/{id}/refunds."