There is no shortage of REST style guides, and most of them agree on the easy parts. What they rarely do is explain why a given choice is the pragmatic one, or show what it costs when you get it wrong. This post goes through the six decisions that come up in every HTTP API I design, picks a default for each, and tries to justify it with something stronger than taste: an RFC, a measurement, or a failure mode.

Resources and naming

A resource is a noun the client can point at. Verbs belong in the HTTP method, not in the path.

Instead of Use Why
POST /createOrder POST /orders the method already says "create"
GET /orders/getById?id=42 GET /orders/42 identity in the path is cacheable and linkable
GET /customers/7/orders/42/items/3/product GET /products/19 nest at most one level; deep paths repeat what the client knows
GET /order GET /orders collections are plural, consistently
POST /orders/42/cancel POST /orders/42/cancellations or PATCH /orders/42 a state change is either a sub-resource or an update of the parent

Plural nouns, lower-case, hyphens if you must. The one exception I allow is an action that is not a state of anything, like POST /orders/42/receipt-email, and even then I try to model it as creating a resource (an "email request") that has its own status.

Versioning

Put the major version in the path: /v1/orders. It is visible in logs, trivial to route, and impossible to forget in a curl command. Header-based versioning is more "pure" and almost nobody uses it correctly from a shell.

More important than where the version lives is how rarely it changes. Treat these as non-breaking and ship them inside v1:

  • adding a field to a response,
  • adding an optional request parameter,
  • adding a new endpoint or a new enum value when the documentation told clients to tolerate unknown values.

Everything else (renaming, removing, changing a type, changing the meaning of a status code) is a v2, and v2 should be rare enough that you can afford to run v1 alongside it for a year with a Deprecation header on every response.

Pagination: cursor, not offset

?offset=10000&limit=50 is the pagination everyone knows and the one I avoid for anything that grows. Two reasons, one about correctness and one about cost.

Correctness: if a row is inserted (or deleted) while the client is paging, every later page shifts by one. The client sees a duplicate or misses a row and has no way to detect it.

Cost: the database has to produce and discard 10,000 rows to return page 201. I measured this on a 2,000,000-row SQLite table with an index on (created_at, id), fetching 50 rows at increasing depths, best of 20 runs each:

Line chart on linear axes: offset pagination latency grows from 0.06 ms at depth 0 to 93 ms at depth 1.9 million rows; cursor pagination stays flat near 0.06 ms
Offset pagination climbs from 0.06 ms to 93 ms as the page gets deeper; keyset pagination stays at 0.06 ms regardless of depth. SQLite 3.49 on a laptop; PostgreSQL shows the same shape because OFFSET works the same way.

Keyset (cursor) pagination replaces "skip n rows" with "seek to the last row I saw":

-- offset: walks and discards 10,000 index entries
SELECT id, created_at, customer, amount
  FROM orders
 ORDER BY created_at, id
 LIMIT 50 OFFSET 10000;

-- keyset: one B-tree seek, then 50 reads
SELECT id, created_at, customer, amount
  FROM orders
 WHERE (created_at, id) > ($1, $2)   -- values from the last row of the previous page
 ORDER BY created_at, id
 LIMIT 50;
Two ways to fetch "the next 50 orders" from an index on (created_at, id) Offset pagination GET /orders?limit=50&offset=10000 ORDER BY created_at, id LIMIT 50 OFFSET 10000 10,000 index entries walked and thrown away 50 kept rest of index cost grows with the page number; a row inserted while you page shifts everything after it (duplicates or gaps) Cursor (keyset) pagination GET /orders?limit=50&cursor=eyJjIjoiMjAyNS0wMS0yMVQxMDowMDowMFoiLCJpZCI6NDIwfQ WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id LIMIT 50 skipped by a B-tree seek, never read 50 kept rest of index one seek to the cursor position constant cost at any depth; a stable position even when rows are inserted; the client cannot jump to page 37 the cursor is an opaque base64url encoding of (created_at, id) of the last row returned
Offset walks the index from the start; a cursor seeks straight to the position encoded in it.

The API encodes (created_at, id) of the last returned row as an opaque string, so the client never sees or constructs it:

{
  "data": [{ "id": 420, "created_at": "2025-05-27T03:00:00Z", "amount": "12.50" }],
  "next_cursor": "eyJjIjoiMjAyNS0wNS0yN1QwMzowMDowMFoiLCJpZCI6NDIwfQ",
  "has_more": true
}

The sort key must be unique and stable, which is why id is included as a tiebreaker. You lose "jump to page 37"; in my experience nobody who paged past page 3 wanted page 37 anyway.

Idempotency keys

Networks fail after the server commits and before the client reads the response. Without help, the client's retry creates a second order. The fix is a client-generated Idempotency-Key header on every unsafe request, and a server that stores the first outcome keyed by it.

Server sees Response
new key process normally, store (key, request fingerprint, response)
same key, same fingerprint, finished replay the stored response, same status and body
same key, same fingerprint, still in flight 409 Conflict with Retry-After
same key, different fingerprint 422 Unprocessable Content: the client reused a key by mistake

The storage is a small table with a TTL of about 24 hours; the fingerprint is a hash of method, path and body so that a reused key with a different payload is caught instead of silently replayed. The IETF HTTP API working group has a draft that standardises exactly this header and these semantics, linked below.

TIP

Generate the key on the client at the moment the intent is created (the user pressed "Pay"), not at the moment the request is sent. Retries then share the key automatically, and a second click produces a second key, which is what you want.

Errors: problem details, not ad-hoc JSON

Every API eventually grows three incompatible error shapes. RFC 9457 ends that with one media type, application/problem+json, and five well-defined members, plus room for your own:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
Content-Language: en

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient funds",
  "status": 422,
  "detail": "The account balance is 12.50 but the order total is 40.00.",
  "instance": "/v1/orders/req_01HZX3",
  "balance": "12.50",
  "required": "40.00"
}

type is a stable identifier clients can switch on; detail is for humans and may change wording freely. The status code still carries the coarse meaning: use 400 for malformed syntax, 422 for well-formed but unprocessable input, 404 for a missing resource, 409 for a state conflict, and keep 500 for things that are genuinely your fault.

Rate limiting

Return 429 Too Many Requests with a Retry-After header, and tell clients where they stand before they hit the limit. The IETF draft for standard RateLimit headers (also linked below) is the direction things are going; the X-RateLimit-* family is what most clients already parse, so I send both while the draft settles:

HTTP/1.1 200 OK
RateLimit-Policy: 100;w=60
RateLimit: 100;r=37;t=22
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 37
X-RateLimit-Reset: 22

Rate limits belong on the gateway or in middleware, keyed by API key (not IP), and every limited response should still be a problem-details document so clients only need one error parser.

Putting it in OpenAPI

Everything above is expressible in OpenAPI 3.1, which means it can be linted in CI and turned into typed clients. The excerpt below covers the create-order operation with the idempotency header, the problem response, and the cursor-paginated list:

openapi: 3.1.0
info: { title: Orders API, version: 1.0.0 }
paths:
  /v1/orders:
    get:
      summary: List orders, newest last
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaque; take it from next_cursor }
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                required: [data, has_more]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Order" } }
                  next_cursor: { type: [string, "null"] }
                  has_more: { type: boolean }
    post:
      summary: Create an order
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NewOrder" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { $ref: "#/components/schemas/Order" } } } }
        "409": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
components:
  responses:
    Problem:
      description: RFC 9457 problem details
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
  schemas:
    Problem:
      type: object
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }

The same request, end to end

The tabs below show one create-order call under the three outcomes the idempotency table describes.

  • POST /v1/orders HTTP/1.1
    Host: api.example.com
    Content-Type: application/json
    Idempotency-Key: 6f1c2a1e-6e04-4a6a-9a1c-3e2b1d6c9f10
    
    { "customer_id": 7, "items": [{ "sku": "A-100", "qty": 2 }] }
    
    HTTP/1.1 201 Created
    Content-Type: application/json
    Location: /v1/orders/420
    RateLimit: 100;r=99;t=60
    
    { "id": 420, "status": "pending", "total": "40.00", "created_at": "2025-05-27T03:00:00Z" }
    
  • The client never saw the 201, so it sends the identical request with the same key. The server finds the stored outcome and replays it byte for byte; no second order exists.

    HTTP/1.1 201 Created
    Content-Type: application/json
    Location: /v1/orders/420
    Idempotent-Replayed: true
    
    { "id": 420, "status": "pending", "total": "40.00", "created_at": "2025-05-27T03:00:00Z" }
    
  • HTTP/1.1 422 Unprocessable Content
    Content-Type: application/problem+json
    
    {
      "type": "https://api.example.com/problems/idempotency-key-reused",
      "title": "Idempotency key reused with a different request",
      "status": 422,
      "detail": "Key 6f1c2a1e-... was first used with a different request body. Use a new key for a new request.",
      "instance": "/v1/orders"
    }
    
Why 422 and not 400 for a reused key

400 Bad Request means the server could not understand the request: malformed JSON, an invalid header, a body that does not parse. A reused idempotency key with a different body is perfectly well-formed; the server understood it completely and refuses it on semantic grounds. That is what 422 Unprocessable Content is for. Keeping this distinction lets clients treat 400 as "fix the code" and 422 as "fix the data", which is a useful split in practice.

Further reading