HTTP API design in practice

Applying method semantics, pagination strategies, ETags in APIs, problem-detail error bodies, versioning, webhooks and delivery guarantees.

Methods and pagination

GET    /v1/orders?status=open&limit=50&cursor=eyJpZCI6MTA0Mn0  # list
POST   /v1/orders                                                  # create
GET    /v1/orders/1042                                             # read one
PATCH  /v1/orders/1042                                             # partial update
DELETE /v1/orders/1042                                             # remove

HTTP/1.1 201 Created
Location: /v1/orders/1042
ETag: "7"

HTTP/1.1 200 OK
{
  "data": [ { "id": 1042, "status": "pending" } ],
  "page": { "next_cursor": "eyJpZCI6MTA0MH0", "has_more": true }
}
StrategyRequestTrade-off
Offset?limit=50&offset=200Easy, but rows shift when the underlying data changes mid-page
Cursor / keyset?limit=50&cursor=...Stable and fast on large tables; no random access to a page number
Time window?since=2026-09-01T00:00:00ZGood for feeds; needs a tiebreaker on equal timestamps
  • Always cap limit server-side. A client asking for a million rows should receive an error or a capped page, not a database meltdown.
  • Return a total count only when it is cheap; a separate lightweight endpoint beats a COUNT(*) on every list call.
  • POST for a complex search is a legitimate escape hatch when the filter does not fit a URL, even though it is not cacheable by default.

Conditional writes, errors and versioning

PUT /v1/orders/1042 HTTP/1.1
If-Match: "7"
Content-Type: application/json

{"status":"shipped"}

HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/stale-version",
  "title": "Resource changed",
  "status": 412,
  "detail": "The order was modified at 12:04 by another client.",
  "instance": "/v1/orders/1042"
}
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/out-of-stock",
  "title": "Out of stock",
  "status": 409,
  "detail": "SKU A-1 has 0 units available.",
  "errors": [ { "field": "qty", "issue": "exceeds_available" } ]
}
ChangeVersioning approachCost
Additive field or endpointNo new versionNone — clients ignore unknown fields
New required parameter or semanticsNew version (/v2 or a media type)Runs two code paths
DeprecationDeprecation and Sunset headersRequires client follow-through
  • ETag plus If-Match gives you optimistic concurrency with no locks: a 412 means someone else won the race and the client should re-read.
  • Use application/problem+json so every service returns errors with the same shape — that is what lets a client render a useful message without special-casing endpoints.
  • Keep a stable, machine-readable type URI; humans read title, code branches on type.
  • Adding a field is not a breaking change. Removing one, tightening validation, or changing a default is.

Webhooks and delivery guarantees

POST /hooks/orders HTTP/1.1
Host: consumer.example.com
Content-Type: application/json
X-Event-Id: 01J8Y7Q2ZK3M4N5P6R7S8T9V0W
X-Event-Timestamp: 1770000000
X-Signature: t=1770000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"type":"order.shipped","data":{"id":1042,"status":"shipped"}}
import crypto from 'node:crypto';

export function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('=')));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (age > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
GuaranteeWhat it meansWhat the receiver must do
At most onceEvents can be lostNothing — acceptable only for analytics
At least onceEvents arrive, possibly twiceDeduplicate on the event id
Exactly onceNot achievable end to end over a networkDesign for at-least-once plus idempotent handlers
💡
Every webhook consumer needs three things: signature verification over the raw body, an event id stored to make handling idempotent, and a fast acknowledgement with the real work queued. A handler that does its work inline before replying will eventually be killed by a timeout and retried.

FAQ

Should pagination use cursors or page numbers?
Cursors for anything that changes or grows; page numbers only for small, stable, user-facing lists where jumping to page 12 matters.
How do I version an API without maintaining two codebases?
Keep the same handlers and translate at the edge, or use additive, tolerant changes so old clients keep working. A version number is a promise to support the old shape for a defined period, so add one only when you must break something.

Cookies, sessions and state Proxies, load balancers and connection management

Last refreshed 2026-09-18.