← Back to the blog

API design that ages well

Every API ages. The question is whether it ages like wine or like milk. Six rules we apply on every public surface we ship.

Every API ages. The question is whether it ages like wine or like milk. Six rules we apply on every public surface we ship — internal or external.

1. Names outlive everything

You can change the database, swap the framework, rewrite the backend. You cannot easily change a field name once a customer is reading it. Spend the time. created_at, not cAt.customer_id, not cid. cancelled, not canceled (or pick one and never deviate).

2. Version at the boundary

Put the version in the URL or a header — pick one and commit. Then treat each version as a frozen contract. The instinct to "just add a field" is fine for additive changes. It is poison the first time you need to change a meaning.

The ruleAdditive changes go in the current version. Breaking changes get a new version. There is no third option, no matter how convenient it seems on a Wednesday afternoon.

3. Pagination on day one

Every list endpoint paginates. Every one. Even the ones where "there will only ever be ten of them." There won't. Cursor-based pagination is the right default — offset breaks under concurrent inserts and gets quadratically slow.

// Cursor pagination, opaque to the client.
GET /v1/invoices?limit=50&cursor=eyJpZCI6MTIzfQ

// Response includes the next cursor (null if done).
{
  "data": [...],
  "next_cursor": "eyJpZCI6MTczfQ"
}

4. Errors are part of the contract

An error response is part of your API surface, not an afterthought. It needs a stable code, a human message, and a structured body. Customers will write switch statements on your error codes. Treat that as a contract.

{
  "error": {
    "code": "invoice_not_found",
    "message": "No invoice with that id.",
    "request_id": "req_01HXY..."
  }
}

5. Idempotency keys

Any non-idempotent endpoint — anything that creates, charges, or sends — needs an Idempotency-Keyheader. Stripe popularised the pattern; it's now the default expectation. The first time a client retries a duplicate charge, you'll be glad you put it in v1.

6. Timestamps everywhere

Every record has created_at and updated_at. ISO 8601, UTC, never local. The day you need to debug a race condition or an audit log, you'll need both. The day you need to answer "when did this status change?" you'll wish for a third one — status_changed_at — so add that too.

None of these rules are clever. They're just the ones that compound. We've seen what happens to APIs that skipped them — it's in most of our migration case studies.

★ ★ ★

End of article · Thanks for reading

Subscribe

More of this, once a month.