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

  1. Client-server. The user interface (client) and data storage (server) are separate, so each can change on its own.
  2. 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.
  3. Cacheable. Responses say whether they may be cached, so clients and intermediaries can reuse them; see HTTP Caching.
  4. 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.
  5. Layered system. A client cannot tell whether it talks to the real server or to a proxy, cache, load balancer or gateway in between.
  6. 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.
A REST resource tree. The root /v1 has two collections, /tickets and /customers. Under /tickets is an item, /tickets/{id}, which has a sub-collection /tickets/{id}/comments, which contains items /tickets/{id}/comments/{commentId}. Under /customers is the item /customers/{id}, which has a sub-collection /customers/{id}/meters. /v1 /tickets /customers /{id} /{id} /comments /meters /{commentId} collection item GET /v1/tickets/42/comments/3
Collections (shaded) contain items; items can own sub-collections. Each box adds one segment to the URL.

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, PUT and DELETE are idempotent by definition in the HTTP standard (RFC 9110). Sending PUT /tickets/43 with the same body twice leaves the same ticket. A second DELETE may answer 404 instead of 204 — the response differs, but the state is the same: the ticket is gone.
  • POST is not idempotent. Two identical POST /tickets requests create two tickets. PATCH is 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 Link header with rel="next"; the second page has none, so the client knows it is done.
  • limit=all is malformed for this API, so the answer is 400. A POST without a title is well-formed JSON with invalid content, so the answer is 422.
  • POST answers 201 Created with the new URL in Location; the client does not have to guess the ID.
  • PUT sends the complete ticket (title and status). DELETE answers 204 with no body at all, and a later GET gives 404.
  • The server sets the reason phrase for 422 itself, 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 /tickets without 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_at in one resource, createdAt in another and creationDate in a third. Pick one convention and keep it.