Skip to content
API Design

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.

Tareq Hasan5 min read
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 /getUserGET /users/:id
POST /createInvoicePOST /invoices
POST /updateUserPATCH /users/:id
POST /sendInvoiceEmailPOST /invoices/:id/emails (a sub-resource)
POST /deleteUserDELETE /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.

  • 200 OK — success. If the operation created something, make it 201 Created with a Location header.
  • 202 Accepted — kicked off an async job; return a job resource to poll.
  • 204 No Content — success with no body (good default for DELETE).
  • 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 with Retry-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.envelope.json
{
  "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:

  • code is the machine-readable contract — document every code in your API reference. Clients branch on code, never on message text.
  • message is human-readable and localized client-side, not server-side, if you need i18n.
  • request_id lets 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.
  • details carries field-level validation so forms can render inline errors without parsing prose.

On the client, one error type:

api-client.ts
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:

pagination.response.json
{
  "data": [
    { "id": "…", "created_at": "…", "…": "…" }
  ],
  "pagination": {
    "has_more": true,
    "next_cursor": "eyJzb3J0X2tleSI6IjIwMjYtMDYtMTgiLCJpZCI6InVzZXJfMTIzIn0="
  }
}
  • Sort on a stable compound key (e.g., (id, created_at)), not created_at alone, 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 v1 in the URL makes your compatibility promise explicit to humans, not just machines.
  • Adding v2 is 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:

  1. Additive changes are not version bumps. New optional field? New endpoint? New code in the error envelope? Ship it in v1. Only break semantics → new version.
  2. 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.
  3. 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.