Designing HTTP APIs That Age Well: Resources, Errors & Versioning
Nouns over verbs, consistent error envelopes, pagination you can't outgrow, and the versioning strategy that keeps breaking changes from breaking your customers.
On this page
The most expensive decision in API design isn't the auth scheme — it's the shape of your resource model, because everything downstream (SDKs, client code, internal services, and eventually your open source reputation) inherits it. A REST API that half-heartedly follows the constraints of HTTP is worse than an honest RPC API, because clients will assume the HTTP semantics work and get burned when they don't.
This article is about the decisions that make an HTTP API correct on day one and still correct in year three: resource modeling, error envelopes, pagination, and the only versioning strategy I recommend to product teams.
Nouns over verbs
The single highest-signal difference between "an API someone built" and "an API someone designed" is whether the surface is resource-centric or action-centric.
| BAD (verb-centric) | GOOD (resource-centric) |
|---|---|
POST /getUser | GET /users/:id |
POST /createInvoice | POST /invoices |
POST /updateUser | PATCH /users/:id |
POST /sendInvoiceEmail | POST /invoices/:id/emails (a sub-resource) |
POST /deleteUser | DELETE /users/:id |
The rule of thumb: if the operation name contains an HTTP verb or a SQL keyword, it's probably a smell. Sub-resources (/invoices/:id/emails) let you model actions as resources, which gives you tracking, retries, and idempotency for free — an email send that's really a POST /invoices/:id/emails returns a 201 with a body you can poll.
There are legitimate actions (POST /users/:id/activate, complex searches) that don't map to a CRUD verb. That's fine: POST-only actions that mutate state are acceptable as long as they're idempotent-safe to retry and documented as actions. The goal is consistency, not dogma.
The status code contract
Teams ship APIs where every failure is a 400 and every success is a 200 "with an error field in the body." Stop. Status codes are the cheapest, most standard form of documentation you have — clients, proxies, and monitoring tools all understand them without reading your docs.
200OK — success. If the operation created something, make it201 Createdwith aLocationheader.202 Accepted— kicked off an async job; return a job resource to poll.204 No Content— success with no body (good default forDELETE).400 Bad Request— malformed request (validation failure).401 Unauthorized— missing or invalid credentials.403 Forbidden— authenticated but not allowed.404 Not Found— resource, or ID, doesn't exist. Also use it for "exists but you must not see it" to avoid enumeration leaks — a skipped-on-purpose 404 is a known quantity in every serious API.409 Conflict— violates current state (e.g., duplicate email on signup).422 Unprocessable Content— semantically valid but rejects (business rule).429 Too Many Requests— rate limited, always withRetry-After.
Caution
The 400-vs-422 split trips everyone up. Use 400 for "we couldn't parse/validate your request" and 422 for "your request is well-formed but violates a state rule." If you conflate them, clients have to string-match your error messages, which is how APIs become static-analysis-only-from-500 errors.
A consistent error envelope
Every error response should share one shape, because SDK authors will write one deserializer for it:
{
"error": {
"code": "email_already_registered",
"message": "An account with this email already exists.",
"status": 409,
"request_id": "0b7f3a2e-…",
"details": [
{
"field": "email",
"code": "unique_violation",
"message": "is already taken"
}
]
}
}Notes earned from shipping this envelope:
codeis the machine-readable contract — document every code in your API reference. Clients branch oncode, never onmessagetext.messageis human-readable and localized client-side, not server-side, if you need i18n.request_idlets deep-linking support tickets to logs. Print it in your server logs too — then "it broke around 2pm" becomes a 20-second grep instead of a 40-minute hunt.detailscarries field-level validation so forms can render inline errors without parsing prose.
On the client, one error type:
export interface ApiErrorBody {
error: {
code: string;
message: string;
status: number;
request_id: string;
details?: { field: string; code: string; message: string }[];
};
}
export class ApiError extends Error {
constructor(public body: ApiErrorBody, public status: number) {
super(body.error.message);
this.name = "ApiError";
}
}Pagination you can't outgrow
Cursor-based pagination beats offset pagination the moment your table isn't frozen. Offset is fine for admin tables of 50 rows; for a feed or an audit log it degrades into an O(n²) tail-scan under OFFSET 500000.
Prefer a cursor design, and make the cursor an opaque string:
{
"data": [
{ "id": "…", "created_at": "…", "…": "…" }
],
"pagination": {
"has_more": true,
"next_cursor": "eyJzb3J0X2tleSI6IjIwMjYtMDYtMTgiLCJpZCI6InVzZXJfMTIzIn0="
}
}- Sort on a stable compound key (e.g.,
(id, created_at)), notcreated_atalone, so ties can't skip or duplicate rows. - Cursor maps to a
WHERE (id, created_at) > (cursor…)predicate — index-friendly, stable under concurrent inserts. - Return
has_more; clients stop early instead of guessing by page size. - Set a max page size (e.g.,
limit <= 100) and clamp it. Let "fetch all" be an explicit, documented bulk endpoint with proper backpressure instead.
Versioning: URL path, header, or nothing?
Three camps exist: /v1/users, Accept: application/vnd.api+json;version=2, and "just evolve it." My recommendation for product APIs:
Use /v1 from day one. Here's the reasoning:
- Path versioning is visible in every log line and every support ticket — a
v1in the URL makes your compatibility promise explicit to humans, not just machines. - Adding
v2is a non-event operationally; the old route keeps serving while you migrate. - Header versioning is "cleaner" until you remember that every proxy, cache, and curl-from-a-forum now has to know to send a header to get correct behavior. Base URLs are caches' favorite key.
The discipline that makes versioning cheap:
- Additive changes are not version bumps. New optional field? New endpoint? New
codein the error envelope? Ship it in v1. Only break semantics → new version. - Deprecate with a timestamp, not a whim. Announce
Sunset-header-style end dates, log usage of deprecated fields, and give at least 6 months for a documented major. - Never silently change a response field's meaning. If
status: "active"now means something narrower, that's a breaking change and deserves a version bump + changelog entry.
Note
Public-facing SDKs and public docs should track a "sunset" policy explicitly in the API reference — a deprecation table with deprecated_at and sunset_at columns. It costs one small endpoint to maintain and removes the single most common support question ("is ephemeral_keys still safe to use?").
What to test instead of what to debug
A production API without contract tests is a production API you're debugging in front of customers. Add these to the pipeline early:
- Contract tests (e.g., via OpenAPI + a schema validator) asserting the documented shape matches the response — cheap insurance against a refactor silently changing a field type.
- Backward-compat tests that run the previous version's fixture expectations against the current server.
- Query param fuzzing on pagination and nested includes, which is where most "works in Postman" bugs live.
The finish line
An HTTP API that ages well is boring in exactly the right ways: nouns, correct status codes, one error envelope, cursor pagination, and a visible version in the URL. Every one of these decisions is cheap on day one and punishing to retrofit — which is precisely why they belong in your first pull request, not your last incident review.
Work together
Building something similar?
I design and build production-ready web applications, SaaS platforms and backend APIs — from data modelling to deployment. If a project or technical challenge is on your roadmap, let's talk it through.