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 }
}| Strategy | Request | Trade-off |
|---|---|---|
| Offset | ?limit=50&offset=200 | Easy, 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:00Z | Good for feeds; needs a tiebreaker on equal timestamps |
- Always cap
limitserver-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. POSTfor 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" } ]
}| Change | Versioning approach | Cost |
|---|---|---|
| Additive field or endpoint | No new version | None — clients ignore unknown fields |
| New required parameter or semantics | New version (/v2 or a media type) | Runs two code paths |
| Deprecation | Deprecation and Sunset headers | Requires client follow-through |
ETagplusIf-Matchgives you optimistic concurrency with no locks: a412means someone else won the race and the client should re-read.- Use
application/problem+jsonso 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
typeURI; humans readtitle, code branches ontype. - 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));
}| Guarantee | What it means | What the receiver must do |
|---|---|---|
| At most once | Events can be lost | Nothing — acceptable only for analytics |
| At least once | Events arrive, possibly twice | Deduplicate on the event id |
| Exactly once | Not achievable end to end over a network | Design 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.
Related
Cookies, sessions and state Proxies, load balancers and connection management
Last refreshed 2026-09-18.