Designing an API Other People Will Use: Errors, Paging, Safe Retries and Limits

Conventions that keep your API from breaking other people's apps: path naming, one shape for errors, cursor paging, versioning, protection against duplicate orders, and call limits.

Phát LâmPublished Updated 8 min readIntermediate
Designing an API Other People Will Use: Errors, Paging, Safe Retries and Limits
On this page
  1. Naming paths
  2. Report errors in one shape
  3. Paging with a cursor
  4. Versions and retirement
  5. Protection against duplicate orders
  6. Limiting calls
  7. For readers who write code
  8. Mistakes I keep seeing
  9. What to do next

A partner connects their app to your API. Three months later you rename a field in the response "to tidy it up", and their app stops working without you ever knowing. When you change an internal function, you fix its callers in the same commit. When you change a public API, someone out of your sight breaks.

That is why I spend time on design before writing code. The goal is a predictable API: a reader learns once what an error looks like, where the next page is, whether a retry is safe, and how many calls are left. This post covers the conventions I reach for: naming paths, reporting errors, paging, versioning, and two things people forget, protection against duplicate orders and call limits.

A central cube linked by thin lines to five resource cards with simple icons
Design around resources (nouns), not around actions.

Naming paths

A path is a noun, not a verb. The action already lives in the HTTP method.

  • Plural nouns: /v1/orders, /v1/orders/42. Not /getOrder or /createOrder.
  • Nest at most one level: /v1/orders/42/items. Deeper is hard to read, so give the child its own path.
  • If an action is not a plain create, read, update or delete, treat it as a thing that can be created: POST /v1/orders/42/cancellation instead of POST /v1/cancelOrder.

Methods should match the job: GET to read (returns 200), POST to create (201), PUT to replace everything, PATCH to change part, DELETE to remove (204). One concept to keep: "idempotent" means calling once or ten times leaves the same end result. GET, PUT and DELETE have it, POST does not. That decides whether a client may retry on its own after a network drop, and leads into the duplicate-order section below.

Report errors in one shape

Instead of a different style per endpoint, use the standard shape from RFC 9457 (content type application/problem+json, which replaced RFC 7807 in 2023). It has four main fields: type (an identifier for the kind of error), title (a short, stable summary), status (the HTTP code), detail (what happened this time). You can add your own fields, such as a list of errors per input.

An error should carry the right HTTP code, because scanners, proxies and monitors only look at that. The codes I use: 400 unreadable request (broken JSON), 401 not signed in, 403 signed in but not allowed, 404 not found, 409 conflicts with current state, 422 readable but breaks a rule, 429 too many calls, 5xx the server's fault. For 5xx never put internal details (a SQL statement, a server path) in detail. For signing callers in, see A Shared JWT Login Token for Node.js, PHP, Python and C#.

Paging with a cursor

Lists need paging, in two flavors. Page numbers (?page=3) are easy but get slower on deep pages, and if data changes between two calls you see repeats or gaps. A cursor returns a marker of where you stopped, and the client sends it back to get the next page. The database reasons are in Slow Query? How to Read EXPLAIN and Put Indexes in the Right Place.

The cursor should be opaque to the client: pass it back untouched and never build one, so you can change how it is encoded later without breaking anyone. Cap the number of rows per page. To know you are on the last page, fetch limit + 1 rows and see whether one is left over.

Versions and retirement

I put the version in the path (/v1/...) because it is easy to see, cache and debug in a browser. More important than where the number lives is discipline about change. Adding a field or a path needs no new version, and clients should be told to ignore fields they do not know. Renaming, removing or retyping a field is a breaking change and needs a new version.

When you retire an old version, warn with headers: Deprecation says when it counts as obsolete (RFC 9745), Sunset says when it stops entirely (RFC 8594), and Link with rel="successor-version" points to the new one.

Protection against duplicate orders

A real scenario: the app calls POST /orders, the server creates the order and charges the card, but the response is lost when the connection drops. The app does not know the order exists, so it retries. The customer is charged twice.

The common cure is an idempotency key. The app generates a random code for each intention to create an order and sends it in a header. The first time, the server processes normally and stores the result under that code. If the code arrives again with identical content, the server does not redo the work and replays the old result. If it arrives with different content, that is an error. This header is still an IETF draft (and an expired one), not a final standard, but many payment services use it.

A request cube passing through three gates in a row (shield, keyhole, quota gauge) to a response cube
A request passes the gates: validation, idempotency, rate limit.

Limiting calls

A rate limit protects the server from clients stuck in a loop and from abuse. Just as important is telling clients their quota so they can slow down on their own. The IETF draft draft-ietf-httpapi-ratelimit-headers (version 11) defines two headers, RateLimit-Policy and RateLimit. Many APIs still use older names such as X-RateLimit-Remaining. Pick one style, document it, and stay consistent. When the quota is exceeded, return 429 with Retry-After.

For readers who write code

I built a small Node.js 20 server with no libraries and tested each point with curl.

The error helper:

function problem(res, status, title, detail, extra = {}, headers = {}) {
  const body = { type: `https://api.example.com/problems/${extra.code}`, title, status, detail, ...extra };
  delete body.code;
  res.writeHead(status, { "content-type": "application/problem+json", ...headers });
  res.end(JSON.stringify(body));
}

Sending bad data returns:

{"type":"https://api.example.com/problems/validation","title":"Dữ liệu không hợp lệ","status":422,
 "detail":"Một hoặc nhiều trường sai.",
 "errors":[{"field":"item","message":"tối thiểu 3 ký tự"},{"field":"qty","message":"số nguyên từ 1 đến 100"}]}

GET /v1/orders/9999 gives 404 with content-type: application/problem+json. For cursor paging, I walked all 95 orders with limit=40 and got 3 pages (40, 40, 15); the last page has next_cursor: null, and the Link header carries the next page address (rel="next"). The /v0/orders route returns Deprecation: @1767225600, a Sunset date and a Link to /v1/orders.

The duplicate protection:

const key = String(req.headers["idempotency-key"]).replace(/^"|"$/g, "");
const hash = sha(JSON.stringify(body));
const prev = idem.get(key);
if (prev) {
  if (prev.hash !== hash) return problem(res, 422, "Idempotency-Key bị dùng lại", "Khoá này đã dùng với nội dung khác.", { code: "idempotency-key-reused" });
  if (prev.state === "processing") return problem(res, 409, "Yêu cầu đang xử lý", "Thử lại sau.", { code: "idempotency-in-flight" }, { "retry-after": "1" });
  return json(res, prev.status, prev.body, { "idempotent-replayed": "true" });   // replay the old result
}
idem.set(key, { hash, state: "processing" });
// ... do the real work, then store { hash, state: "done", status: 201, body }

Measured: first call with key t-1 gave 201, order 96. The second call, same key and content: still 201, still order 96, with idempotent-replayed: true and no new order. The third call, same key but a different qty: 422. In an earlier run I also sent three parallel requests with one new key and got 409, 409, 201, with only one order created. In real life you need to store keys in durable storage or Redis with a lifetime (commonly 24 hours), scope keys per user, and only record the result when the work is done.

Call limits: I set 5 calls per 10 seconds and sent 7 in a row. The first five returned 200 with RateLimit: "default";r=4;t=10 falling to r=0. The sixth and seventh returned 429 with Retry-After: 10 and a problem+json body.

My counter is a fixed window per IP, so it has a weakness: a client can send a full quota at the end of one window and another at the start of the next. For a real API use a token bucket or a sliding window, count in Redis so several processes share it, and key by API key or user rather than IP alone, since a whole office can share one IP. Storing a counter with a lifetime works like in Make Your API Faster With Redis: Save Answers So the Database Gets a Break.

Mistakes I keep seeing

The most common is returning 200 with {"success": false} for an error. The monitor looks at the HTTP code and sees everything fine while customers cannot order.

Next is paging by page number on a big table, or returning an unlimited list. One day the table grows and the endpoint falls over by itself.

And do not rename a field without bumping the version. You think it is a small fix, but for the person using it, it is a break.

What to do next

Take one endpoint you already have and check three things: does every error use the right HTTP code and one shared shape, does the list page with a cursor and a maximum size, and does the POST that creates something important accept an idempotency key? These cost little at the start and a lot after people depend on you.

REST APINode.jsBackendrestapi-designproblem-jsonrfc-9457paginationidempotencyrate-limitversioning

References

  1. RFC 9457: Problem Details for HTTP APIs
  2. RFC 9110: HTTP Semantics
  3. RFC 6585: Additional HTTP Status Codes (429)
  4. RFC 9745: The Deprecation HTTP Response Header Field
  5. RFC 8594: The Sunset HTTP Header Field
  6. RFC 8288: Web Linking
  7. IETF draft: RateLimit header fields for HTTP (draft-ietf-httpapi-ratelimit-headers)
  8. IETF draft: The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header)