---
title: "Designing an API Other People Will Use: Errors, Paging, Safe Retries and Limits"
url: https://phatdev.net/en/blog/rest-api-design-practical
lang: en
updated: 2026-10-21
alternate:
  vi: https://phatdev.net/vi/blog/rest-api-design-practical
markdown: https://phatdev.net/en/blog/rest-api-design-practical.md
---

# 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.

Categories: Backend | Published: 2026-10-19 | Level: Intermediate | 8 min read

Tags: rest, api-design, problem-json, rfc-9457, pagination, idempotency, rate-limit, versioning

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 hub of resources linked to resource cards around it](https://phatdev.net/uploads/blog/rest-api-design-practical/resources-1920.webp)

## 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#](https://phatdev.net/en/blog/jwt-across-languages).

## 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](https://phatdev.net/en/blog/mysql-index-explain).

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 passing a row of gates: checks, the idempotency lock, the quota, then the response](https://phatdev.net/uploads/blog/rest-api-design-practical/request-gates-1920.webp)

## 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:

```ts
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:

```json
{"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:

```ts
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](https://phatdev.net/en/blog/redis-cache-aside-api).

## 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.

## References

- [RFC 9457: Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
- [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
- [RFC 6585: Additional HTTP Status Codes (429)](https://www.rfc-editor.org/rfc/rfc6585)
- [RFC 9745: The Deprecation HTTP Response Header Field](https://www.rfc-editor.org/rfc/rfc9745)
- [RFC 8594: The Sunset HTTP Header Field](https://www.rfc-editor.org/rfc/rfc8594)
- [RFC 8288: Web Linking](https://www.rfc-editor.org/rfc/rfc8288)
- [IETF draft: RateLimit header fields for HTTP (draft-ietf-httpapi-ratelimit-headers)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
- [IETF draft: The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header)](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)

## Related posts

- [Make Your API Faster With Redis: Save Answers So the Database Gets a Break](https://phatdev.net/en/blog/redis-cache-aside-api.md)
- [Slow Query? How to Read EXPLAIN and Put Indexes in the Right Place](https://phatdev.net/en/blog/mysql-index-explain.md)
- [A Shared JWT Login Token for Node.js, PHP, Python and C#](https://phatdev.net/en/blog/jwt-across-languages.md)
