REST APIs
Designing and using resource-oriented HTTP APIs: resources and URLs, methods and status codes, idempotence, filtering and pagination, versioning, and what REST originally meant.
Where REST comes from
REST (representational state transfer) is an architectural style for distributed systems. Roy Fielding — one of the authors of the HTTP specifications — described it in his 2000 PhD dissertation at the University of California, Irvine, as an explanation of why the web itself scales so well. REST is not a protocol or a standard: it is a set of constraints. A system that follows them gets certain properties, such as scalability, simple components and parts that can evolve independently.
The six constraints
- Client-server. The user interface (client) and data storage (server) are separate, so each can change on its own.
- Stateless. Every request carries everything the server needs to understand it — including credentials. The server keeps no conversation state between requests, which makes it easy to put many servers behind a load balancer.
- Cacheable. Responses say whether they may be cached, so clients and intermediaries can reuse them; see HTTP Caching.
- Uniform interface. All resources are handled the same way: they are identified by URLs, manipulated through representations (such as a JSON document), messages describe themselves (method, media type, status), and hypermedia links tell the client what it can do next.
- Layered system. A client cannot tell whether it talks to the real server or to a proxy, cache, load balancer or gateway in between.
- Code on demand (optional). The server may send code for the client to run, such as JavaScript.
Most APIs called "REST" today follow the first five loosely and skip the hypermedia part of the uniform interface (see HATEOAS). In everyday use, "REST API" simply means a resource-oriented API over HTTP, usually with JSON — the REST API you meet in network automation and cloud consoles.
Resources and URLs
A resource is anything the
API exposes that is important enough to have its own identifier: a
ticket, a customer, a meter, a list of outages, even a report that
is calculated on request. The client never touches the resource
itself, only representations of it — usually JSON,
sometimes XML or CSV, chosen with the Accept header.
Collections and items
Resources are named with nouns, in a hierarchy that mirrors how the data belongs together. The common convention:
-
a collection uses a plural noun:
/tickets; -
an item in it adds an identifier:
/tickets/42; -
things that belong to an item are nested under it:
/tickets/42/comments,/customers/7/meters.
Two practical limits: keep nesting shallow (two levels is usually
enough — /comments/3 can be its own top-level resource
if comments have unique IDs), and use the same identifier style
everywhere. Lowercase paths with hyphens
(/meter-readings) are the most common convention.
API endpoints
An API endpoint is one address a
client can call: a URL path together with the HTTP method, such as
GET /v1/tickets or
DELETE /v1/tickets/{id}. Documentation is usually
organized per endpoint. One resource therefore has several endpoints
— one per method it supports. (In security, "endpoint" means a
laptop or server; here it always means an API address.)
Methods and status codes
Mapping CRUD to methods
Most data-handling programs need four operations, known as
CRUD: create, read, update and delete.
In SQL these are INSERT, SELECT,
UPDATE and DELETE; in a REST API they map
onto HTTP methods:
| Operation | Request | Typical success response |
|---|---|---|
| Create |
POST /tickets with the new ticket in the body
|
201 Created,
Location: /tickets/43, the new ticket in the
body
|
| Read (list) | GET /tickets?status=open |
200 OK with a page of tickets |
| Read (one) | GET /tickets/43 |
200 OK with the ticket |
| Replace |
PUT /tickets/43 with the
complete ticket
|
200 OK with the result, or
204 No Content
|
| Update part |
PATCH /tickets/43 with only the changed fields
|
200 OK or 204 No Content |
| Delete | DELETE /tickets/43 |
204 No Content |
PUT replaces the resource with what you send:
a field you leave out is gone (or the server rejects the request).
PATCH changes only the fields you send. Some APIs also
allow PUT to create a resource at a URL the client
chooses; most let the server choose the ID and use
POST for creation.
Status codes for APIs
HTTP defines dozens of status codes (see HTTP in Depth); a typical API uses about ten. Choosing the right one lets a client react without parsing the error text:
| Code | Use it when | The client should |
|---|---|---|
200 OK |
A read or update succeeded and there is a body | Use the body |
201 Created |
A new resource was created; send its URL in
Location
|
Store the new URL or ID |
204 No Content |
Success with nothing to return, typically after
DELETE
|
Not try to parse a body |
400 Bad Request |
The request is malformed: invalid JSON, a parameter that is not a number | Fix the request |
401 Unauthorized |
No valid credentials (despite the name, it means
unauthenticated); include
WWW-Authenticate
|
Get or refresh a token |
403 Forbidden |
The caller is known but not allowed to do this | Not retry; ask for permissions |
404 Not Found |
No resource at this URL (also used to hide resources the caller may not see) | Check the ID or path |
409 Conflict |
The request clashes with the current state: a duplicate unique value, an edit conflict | Reload and decide |
422 Unprocessable Content |
Well-formed, but the content is invalid: a required field missing, an unknown status value | Fix the data |
Add 429 Too Many Requests (rate limited; respect
Retry-After) and the 5xx codes for server-side
failures, and you have the set nearly every API uses. Pair each
error status with a body that explains it;
Designing and Documenting APIs
shows the standard format for that, Problem Details.
Idempotence in practice
Networks fail halfway. A client sends a request, the connection drops, and it never sees the response. Did the server process it? The client cannot know. Whether it may simply try again depends on idempotence: an operation is idempotent when doing it twice has the same effect on the server as doing it once.
-
GET,HEAD,PUTandDELETEare idempotent by definition in the HTTP standard (RFC 9110). SendingPUT /tickets/43with the same body twice leaves the same ticket. A secondDELETEmay answer404instead of204— the response differs, but the state is the same: the ticket is gone. -
POSTis not idempotent. Two identicalPOST /ticketsrequests create two tickets.PATCHis not guaranteed to be idempotent either (think of "add 1 to the counter").
This is why HTTP clients, proxies and SDKs retry idempotent requests
automatically after a network error, but not POST. For
operations that must not happen twice — a payment, an order — many
APIs accept an Idempotency-Key header: the client sends
a unique value per operation, and the server returns the stored
result instead of acting again when it sees the same key. The
pattern is widely used (payment providers popularized it), but as of
2026 it is not an Internet standard: the IETF draft for it expired.
POST /v1/payments HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 9f1c2d7e-4b8a-4c1e-9a55-0d3e6b7f2a10
{"invoice": "INV-2026-0412", "amount": "84.50", "currency": "EUR"}
Filtering, sorting and pagination
Collections can be large, so APIs let the client narrow them down with query parameters. There is no standard syntax; these are common conventions:
GET /v1/tickets?status=open&priority=high&sort=-created_at&limit=50 HTTP/1.1 Host: api.example.com
Here status and priority filter,
sort=-created_at sorts newest first (the minus sign
meaning descending is a common convention), and
limit asks for at most 50 items.
Pagination
Pagination splits a large result into pages that the client fetches one after another, so neither side has to handle thousands of records in one response. Two styles dominate:
| Style | Request | Strengths | Weaknesses |
|---|---|---|---|
| Offset/limit (or page number) | ?offset=100&limit=50 |
Simple; can jump to any page; easy to show "page 3 of 9" | Slow for deep pages (the database still counts past the offset); items shift when data changes, so you can miss or repeat records |
| Cursor (keyset) | ?after=eyJpZCI6MTUwfQ&limit=50 |
Stable while data changes; fast at any depth | Only next/previous, no jumping; the cursor is opaque |
A cursor is an opaque token from the previous response that encodes where the page ended (often the last ID or timestamp). Clients should pass it back unchanged and never try to build one themselves. Large, fast-changing collections — logs, events, audit records — almost always use cursors.
The API also has to tell the client how to get the next page. Some
put a next URL or cursor in the JSON body. Others use
the standard Link header from Web Linking (RFC 8288),
which GitHub's API uses, for example:
HTTP/1.1 200 OK Content-Type: application/json Link: <https://api.example.com/v1/tickets?after=eyJpZCI6MTUwfQ&limit=50>; rel="next"
When there is no next link, you have reached the last
page. A client that follows next links instead of
building URLs itself keeps working when the provider changes its
paging style.
A REST API you can run
The script below runs a small ticket API in memory and then
exercises it with http.client, which shows the status
line and headers exactly. It supports a paged list, reading one
ticket, creating one with POST, replacing it with
PUT and removing it with DELETE, and it
rejects bad input with 400 and 422.
Everything runs on 127.0.0.1;
http.server is fine for learning like this, but not for
production.
import http.client
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
TICKETS = {
1: {"id": 1, "title": "Portal login slow", "status": "open"},
2: {"id": 2, "title": "Meter reading missing", "status": "open"},
3: {"id": 3, "title": "Invoice PDF blank", "status": "closed"},
}
NEXT_ID = 4
class TicketAPI(BaseHTTPRequestHandler):
def route(self):
"""Return (ticket_id or None) for /tickets and /tickets/<id>."""
parts = urlsplit(self.path).path.strip("/").split("/")
if parts[0] != "tickets" or len(parts) > 2:
return "bad"
if len(parts) == 1:
return None
return int(parts[1]) if parts[1].isdigit() else "bad"
def do_GET(self):
ticket_id = self.route()
if ticket_id is None: # the collection, one page at a time
query = parse_qs(urlsplit(self.path).query)
limit = query.get("limit", ["2"])[0]
offset = query.get("offset", ["0"])[0]
if not (limit.isdigit() and offset.isdigit()) or not 1 <= int(limit) <= 100:
return self.reply(400, {"error": "limit 1-100 and offset must be numbers"})
limit, offset = int(limit), int(offset)
items = [TICKETS[k] for k in sorted(TICKETS)][offset:offset + limit]
headers = {}
if offset + limit < len(TICKETS):
headers["Link"] = f'</tickets?limit={limit}&offset={offset + limit}>; rel="next"'
self.reply(200, {"items": items, "total": len(TICKETS)}, headers)
elif ticket_id in TICKETS:
self.reply(200, TICKETS[ticket_id])
else:
self.reply(404, {"error": "ticket not found"})
def do_POST(self):
global NEXT_ID
if self.route() is not None:
return self.reply(405, {"error": "POST goes to /tickets"},
{"Allow": "GET, PUT, DELETE"})
data = self.read_json()
if not isinstance(data, dict) or not data.get("title"):
return self.reply(422, {"error": "title is required"})
ticket = {"id": NEXT_ID, "title": data["title"], "status": "open"}
TICKETS[NEXT_ID] = ticket
NEXT_ID += 1
self.reply(201, ticket, {"Location": f"/tickets/{ticket['id']}"})
def do_PUT(self):
ticket_id = self.route()
if ticket_id not in TICKETS:
return self.reply(404, {"error": "ticket not found"})
data = self.read_json()
if set(data) != {"title", "status"}:
return self.reply(422, {"error": "send the full ticket: title and status"})
TICKETS[ticket_id] = {"id": ticket_id, **data}
self.reply(200, TICKETS[ticket_id])
def do_DELETE(self):
ticket_id = self.route()
if ticket_id not in TICKETS:
return self.reply(404, {"error": "ticket not found"})
del TICKETS[ticket_id]
self.reply(204, None)
def read_json(self):
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length) or b"null")
def reply(self, status, data, headers=None):
# RFC 9110 name for 422; older Pythons print "Unprocessable Entity"
self.send_response(status, "Unprocessable Content" if status == 422 else None)
for name, value in (headers or {}).items():
self.send_header(name, value)
if data is None: # 204 No Content: no body at all
self.end_headers()
return
body = json.dumps(data).encode("utf-8")
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), TicketAPI)
threading.Thread(target=server.serve_forever, daemon=True).start()
conn = http.client.HTTPConnection("127.0.0.1", server.server_port)
def call(method, path, data=None):
body = json.dumps(data) if data is not None else None
headers = {"Content-Type": "application/json"} if body else {}
conn.request(method, path, body=body, headers=headers)
response = conn.getresponse()
raw = response.read().decode("utf-8")
print(f"{method} {path} -> {response.status} {response.reason}")
for name in ("Location", "Link"):
if response.getheader(name):
print(f" {name}: {response.getheader(name)}")
if raw:
print(" " + raw)
call("GET", "/tickets?limit=2&offset=0")
call("GET", "/tickets?limit=2&offset=2")
call("GET", "/tickets?limit=all")
call("GET", "/tickets/2")
call("POST", "/tickets", {"title": "Outage map not loading"})
call("POST", "/tickets", {"status": "open"})
call("PUT", "/tickets/4", {"title": "Outage map not loading", "status": "closed"})
call("DELETE", "/tickets/4")
call("GET", "/tickets/4")
conn.close()
server.shutdown()
server.server_close()
GET /tickets?limit=2&offset=0 -> 200 OK
Link: </tickets?limit=2&offset=2>; rel="next"
{"items": [{"id": 1, "title": "Portal login slow", "status": "open"}, {"id": 2, "title": "Meter reading missing", "status": "open"}], "total": 3}
GET /tickets?limit=2&offset=2 -> 200 OK
{"items": [{"id": 3, "title": "Invoice PDF blank", "status": "closed"}], "total": 3}
GET /tickets?limit=all -> 400 Bad Request
{"error": "limit 1-100 and offset must be numbers"}
GET /tickets/2 -> 200 OK
{"id": 2, "title": "Meter reading missing", "status": "open"}
POST /tickets -> 201 Created
Location: /tickets/4
{"id": 4, "title": "Outage map not loading", "status": "open"}
POST /tickets -> 422 Unprocessable Content
{"error": "title is required"}
PUT /tickets/4 -> 200 OK
{"id": 4, "title": "Outage map not loading", "status": "closed"}
DELETE /tickets/4 -> 204 No Content
GET /tickets/4 -> 404 Not Found
{"error": "ticket not found"}
Things to notice:
-
The first page carries a
Linkheader withrel="next"; the second page has none, so the client knows it is done. -
limit=allis malformed for this API, so the answer is400. APOSTwithout a title is well-formed JSON with invalid content, so the answer is422. -
POSTanswers201 Createdwith the new URL inLocation; the client does not have to guess the ID. -
PUTsends the complete ticket (title and status).DELETEanswers204with no body at all, and a laterGETgives404. -
The server sets the reason phrase for
422itself, because older Python versions still use the pre-2022 name "Unprocessable Entity". Clients should act on the number, never on the phrase.
API versioning
Sooner or later an API needs a change that would break existing clients: a renamed field, a different structure, a removed endpoint. API versioning lets the provider publish the new behavior as a new version while existing clients keep using the old one until they migrate. Changes that do not break clients — a new endpoint, a new optional field — are usually made within a version. There is no standard way to signal the version; four approaches are common:
| Approach | Example | Notes |
|---|---|---|
| URL path | /v1/tickets, /v2/tickets |
Most common and most visible; easy to route and to test in a
browser. Microsoft Graph uses /v1.0 and
/beta.
|
| Query parameter | ?api-version=2024-07-01 |
Used by Azure's management APIs; easy to forget on one call. |
| Request header | Api-Version: 2026-03-01 |
Keeps URLs stable; some providers pin each account to a date-based version and let clients override it per request. |
| Media type | Accept: application/vnd.example.v2+json |
Closest to REST theory (a different representation of the same resource); harder to test by hand. |
Whatever the approach, the important parts are policy, not syntax: announce changes early, run old and new versions side by side, and give clients a clear end date. That lifecycle — breaking changes, deprecation, sunset — is on Designing and Documenting APIs. Many changes need no new version at all if they are made in steps; see expand and contract.
HATEOAS, and why most APIs skip it
HATEOAS (hypermedia as the engine of application state) is the REST constraint that responses contain links telling the client what it can do next — just as a web page contains links and forms. The client needs to know only the entry URL; everything else it discovers by following links.
{
"id": 43,
"title": "Outage map not loading",
"status": "open",
"_links": {
"self": { "href": "/v1/tickets/43" },
"comments": { "href": "/v1/tickets/43/comments" },
"close": { "href": "/v1/tickets/43/close" }
}
}
This ticket offers a close link because it is open; a
closed ticket would not. In theory the server can change its URLs
freely, because clients follow links instead of building them. In
practice, most APIs skip HATEOAS: client developers hard-code paths
from the documentation anyway, there is no single agreed link format
(HAL, used above, is one of several), and generated SDKs make links
less useful. Fielding himself has argued that an API without
hypermedia should not be called REST — so most "REST APIs" are,
strictly speaking, resource-oriented HTTP APIs. Pagination links are
the part of the idea that is widely used.
Common mistakes
A few more that cause real incidents:
-
Unbounded lists.
GET /ticketswithout a default page size works with 30 test records and times out with three million real ones. Always page, with a sensible default and a maximum. -
Leaking internal IDs or fields. Returning the
whole database row exposes fields nobody should see, and accepting
any field on input lets clients set ones they should not (such as
"role": "admin"); see Web and API Security. -
Inconsistent naming.
created_atin one resource,createdAtin another andcreationDatein a third. Pick one convention and keep it.