Short version for the impatient: the four API design rules I actually enforce are stable error shapes, cursor pagination, idempotency keys on anything that charges money, and additive-only changes. Everything else I’ve written down over the years turned out to be preference dressed up as principle.
I know this because I spent a good chunk of last month reviewing an API a client’s team had built, and I kept writing the same four comments. By the fourth PR I copy-pasted them. By the sixth I felt like a bot. So I wrote them down properly, with the reasoning, and now I paste a link instead.
What follows is that document. It’s opinionated, it’s short, and I’ve been wrong about at least one of these before.
Rule 1: pick an error shape once, then never touch it again
The single worst thing about the API I was reviewing wasn’t that the errors were bad. It’s that there were four different kinds of bad. Validation failures came back as {"errors": {"email": ["is invalid"]}}. Auth failures came back as {"message": "unauthorized"}. The payment service returned {"error": {"code": "card_declined", "msg": "..."}}. And one endpoint, memorably, returned a 200 with {"success": false}.
Every client that talked to this API had a little pile of if-statements to figure out which shape it just got. That pile is the actual cost. Not the ugliness.
There’s a standard for this and almost nobody uses it, which I find mildly infuriating. RFC 9457, Problem Details for HTTP APIs, was published in July 2023 and obsoletes RFC 7807 from 2016. It defines a JSON object with five members: type, status, title, detail, and instance. You can add your own fields on top.
Here’s what the old validation error looked like:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
{
"errors": {
"email": ["is invalid"],
"age": ["must be a number"]
}
}
And here’s the same failure as a problem document:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation-error",
"title": "Your request is not valid.",
"status": 422,
"errors": [
{ "pointer": "#/email", "detail": "must be a valid address" },
{ "pointer": "#/age", "detail": "must be a positive integer" }
]
}
The bit that earns its keep is type. It’s a URI, it never changes, and clients switch on it instead of on the status code or on string-matching the message. The RFC is explicit that consumers should treat the type URI as the primary identifier and shouldn’t parse detail for information. I’ve watched two separate teams break clients by rewording an error message, and both times the fix was this.
One practical warning from the security section of the RFC that I ignored for a while: don’t put stack traces in detail. Problem details describe the HTTP interface, not your internals. I once shipped an API where a Postgres constraint name leaked into the error body, which told anyone paying attention exactly what my schema looked like.
Implementing it in a Laravel app is about fifteen lines in the exception handler. In Go it’s a struct and a helper. There’s no framework work here, which is part of why the low adoption bugs me.
Rule 2: offset pagination is a lie your database tells you
I shipped ?page=2&per_page=50 for years. It’s fine right up until the data moves under you, and then it silently duplicates and skips rows, and nobody notices because nobody tests pagination against a table that’s being written to.
Here’s the version I used to write:
// Old: offset pagination
$posts = Post::orderBy('created_at', 'desc')
->skip(($page - 1) * $perPage)
->take($perPage)
->get();
If three posts get inserted between the user’s request for page 1 and page 2, three posts that were on page 1 shift onto page 2. The user sees them twice. If posts get deleted instead, rows slide the other way and vanish entirely. And on a large table, OFFSET 100000 still makes Postgres walk 100,000 rows before discarding them.
The cursor version:
// New: cursor pagination
$query = Post::orderBy('created_at', 'desc')->orderBy('id', 'desc');
if ($cursor = $request->query('page_token')) {
[$ts, $id] = decode_cursor($cursor);
$query->whereRaw('(created_at, id) < (?, ?)', [$ts, $id]);
}
$posts = $query->take($perPage + 1)->get();
$hasMore = $posts->count() > $perPage;
$posts = $posts->take($perPage);
return [
'items' => $posts,
'next_page_token' => $hasMore ? encode_cursor($posts->last()) : null,
];
Two details that took me embarrassingly long to get right. First, you need a tiebreaker column. Sorting by created_at alone breaks the moment two rows share a timestamp, which happens constantly in seeded data and bulk imports. The (created_at, id) row comparison handles it in one clause.
Second, the token has to be opaque. Google’s AIP-158 on pagination is blunt about this: page tokens must be opaque, URL-safe strings that users can’t deconstruct, because the moment someone can parse your token, your pagination internals are part of your public API and you can never change them. I base64 a small signed payload. I’ve also seen people encrypt it. Either beats handing out a raw timestamp and hoping.
AIP-158 also suggests expiring tokens after a few days rather than storing them forever, which I didn’t do and probably should.
The trade-off is real, so I’ll name it: cursors give up random page access. You can’t jump to page 47. If your UI has numbered page buttons, offset is the honest choice and you should accept the drift. For infinite scroll and API consumers, cursors win every time. This is the same kind of decision I went through when I wrote up GraphQL vs REST in 2026 — the answer depends on the client, not on which one reads better in a blog post.
Rule 3: idempotency keys on anything that moves money
A user double-clicks a submit button. Their phone drops to 3G mid-request and the client retries. Your queue worker times out at 30 seconds, the job is re-queued, and the charge goes through twice.
I’ve been on the wrong end of this. It’s a bad afternoon.
The fix is an Idempotency-Key header: the client generates a UUID, sends it with the request, and the server remembers the response for that key. Same key twice, same response, one charge.
// Before: hope for the best
func CreateCharge(w http.ResponseWriter, r *http.Request) {
var req ChargeRequest
json.NewDecoder(r.Body).Decode(&req)
charge, err := billing.Charge(req.Amount, req.CustomerID)
// ... a retry here creates a second charge
}
// After: record the key before doing the work
func CreateCharge(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
problem.Write(w, 400, "missing-idempotency-key")
return
}
if cached, found := store.Get(key); found {
w.WriteHeader(cached.Status)
w.Write(cached.Body)
return
}
// INSERT ... ON CONFLICT DO NOTHING — the DB is the lock
if !store.Claim(key, 24*time.Hour) {
problem.Write(w, 409, "request-in-flight")
return
}
charge, err := billing.Charge(req.Amount, req.CustomerID)
store.Complete(key, 200, charge)
// ...
}
The part people skip is Claim. Checking a cache and then doing the work is a race: two concurrent retries both miss the cache, both charge. You need an atomic claim, and a unique index with ON CONFLICT DO NOTHING is the cheapest one you already have.
Now the honest bit. This header is not a standard. It’s an IETF draft that’s been in progress since 2020, and the document history is a little sad: revision 07 was posted in October 2025, it expired in April 2026, and revisions 02, 04, 05 and 06 all expired before that too. Six years, seven revisions, still an expired draft.
So why follow it? Because Stripe, Square and Adyen all use the same header name and roughly the same semantics, and matching what every payment provider already does is worth more than waiting for an RFC number. Every backend developer who touches your API will recognise it immediately.
Rule 4: add, don’t change
Versioning is the rule I spend the least time on and regret the least. My whole policy is: adding a field is free, removing or renaming one is a breaking change, and breaking changes need a new version.
That’s it. No /v2 unless something genuinely breaks. In practice I’ve shipped one v2 in the last four years, and it was because we changed a resource’s identity model, which is exactly the case that deserves it.
The failure mode I do see constantly is clients that break on new fields. A strict JSON schema with additionalProperties: false, or a Go struct with DisallowUnknownFields, turns your additive change into their outage. Document that clients must ignore unknown fields, the same way RFC 9457 requires consumers to ignore extension members they don’t recognise. Then when someone’s parser blows up on a field you added, you have something to point at.
The other thing I’ve stopped doing is versioning in a custom header. Accept: application/vnd.example.v2+json is technically more correct than /v2/ in the path and I lost that argument three times before I concluded it doesn’t matter. Put it in the path. It’s greppable in logs, it’s obvious in a browser, and nobody has ever been confused by it.
What I’d do this week
Pick one endpoint. Not the whole API, one endpoint that already returns errors.
Convert its error responses to problem details, give the failure a type URI that resolves to a page on your docs site, and add a test that asserts the type URI rather than the message string. It takes an afternoon and it’s the change with the best ratio of effort to future annoyance avoided.
Then go look at your list endpoints and check whether any of them are paginating a table that gets written to while people are reading it. If yes, you have a duplicate-rows bug right now and you haven’t heard about it because it looks like a UI glitch.
I do a fair amount of API and backend work like this, and most of the projects I’ve shipped came down to these same four decisions made early instead of late. None of them are clever. They’re just the ones I’ve paid for by not making.