Designing REST APIs That Stand the Test of Time
A practical tutorial on API design that survives contact with production: resource modeling, status codes, versioning, pagination, error shapes, and idempotency.
APIs are the longest-lived code most teams write. A frontend can be rewritten in a quarter; an API contract, once consumed by real clients, is hard to change without breaking people. This tutorial is about the decisions that make an API survive — the resource model, the status codes, the versioning, the pagination, and the error shape your future self will thank you for.
Resource modeling: nouns, not verbs
REST is about resources (nouns) acted on by HTTP methods (verbs). If your API reads like a procedure list — getUser, createUser, updateUserPassword, banUser — it’s an RPC API wearing REST clothing.
Model the resources, then let the methods do the work:
| Resource | GET | POST | PATCH | DELETE |
|---|---|---|---|---|
/users | List users | Create user | — | — |
/users/{id} | Get user | — | Update user | Delete user |
/users/{id}/orders | List user’s orders | Create order | — | — |
The banUser problem is a great example of modeling thinking. “Ban a user” can be modeled as a sub-resource with its own lifecycle:
POST /users/{id}/suspensions → ban the user
DELETE /users/{id}/suspensions → lift the ban
GET /users/{id}/suspensions → review the ban history
This gives you audit trail, effective dates, and reasons — for free — instead of a PATCH that sets active: false and loses all context.
Status codes are part of the contract
Correct status codes are not decoration; they’re how clients decide what to do. Learn the ones that matter:
- 200 OK, 201 Created (return the created resource with a
Locationheader) - 204 No Content (successful delete)
- 400 Bad Request (malformed / invalid input)
- 401 Unauthorized (no/invalid credentials), 403 Forbidden (not allowed even with credentials)
- 404 Not Found, 409 Conflict (e.g., duplicate email), 422 Unprocessable Entity (semantically invalid)
- 429 Too Many Requests (rate limited — always include a
Retry-Afterheader) - 5xx — never surface internal exception text here
// A good 422
{
"error": {
"code": "VALIDATION_FAILED",
"message": "The request could not be processed.",
"details": [
{
"field": "email",
"reason": "must be a valid email address",
"value": "not-an-email"
}
]
}
}
Error shape: one shape, everywhere
Pick one error schema and use it for every error response. Clients should be able to write one error parser, not twenty.
{
"error": {
"code": "NOT_FOUND",
"message": "No order found with id 'ord_1234'.",
"requestId": "req_0a1b2c3d4e5f",
"details": []
}
}
The code is the machine-readable contract — stable, documented, and specific (INSUFFICIENT_FUNDS, not ERROR). The message is human-readable. The requestId makes support and debugging tractable. Never expose stack traces, SQL, or internal paths.
Versioning: put it in the URL, keep it honest
The Accept-header versioning crowd is technically elegant and practically painful — CDNs, debuggers, and curl get in the way. URL versioning is the boring choice that works:
https://api.example.com/v1/users/{id}
https://api.example.com/v2/users/{id}
Rules that keep versioning sane:
- Version when you change a contract, not when you add a field. Additive changes (new fields, new optional params) don’t need a new version.
- Never silently change a field’s meaning, type, or default.
- Run
v1andv2concurrently, and announcev1deprecation with a real deadline. - Return a deprecation warning header so clients can discover they’re old:
Deprecation: true,Sunset: Sat, 31 Dec 2027 23:59:59 GMT.
Pagination: cursor, not offset
Offset pagination (?page=2&limit=20) breaks when items are inserted or deleted between requests — you get duplicates and skips. Cursor pagination is stable:
GET /orders?limit=20&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTI0IiwiaWQiOiJvcmRfMTAwIn0
The cursor is an opaque token encoding “where I was” (usually a key + id). Responses carry the next cursor:
{
"data": [ /* 20 orders */ ],
"pagination": {
"nextCursor": "eyJjcmVhdGVk...",
"hasMore": true
}
}
The client just passes nextCursor back. It’s stable under concurrent writes, and it’s what every serious API eventually does — start with it.
Idempotency: make retries safe
Networks drop requests. Clients retry. If a retry double-creates an order, your API has a bug by design. Support idempotency keys on state-changing endpoints:
POST /payments
Idempotency-Key: 4a7c-11ec-90d6-0242ac120003
The server keys its cache on the idempotency key: a retry with the same key returns the original response. Implement it with a UNIQUE constraint on (idempotency_key, resource) and a created_at so the key eventually expires.
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
user_id uuid NOT NULL,
response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Consistency: naming, casing, and nulls
Small decisions, repeated thousands of times, become the API’s personality:
- Naming: snake_case or camelCase — pick one and be consistent in the JSON body.
idis a convention; don’t call itID,Id, oridentifier. - Plurals and nesting:
POST /users, never/userand/Users. Keep nesting to 1-2 levels. - Timestamps: ISO 8601 UTC —
2026-07-24T09:30:00Z. Never epoch seconds, never local time. - Nulls: decide whether optional fields are omitted or
null, and document it. Consistency here saves clients from real bugs.
A final checklist
Before you ship an endpoint:
- Resource modeled as a noun, actions as sub-resources
- Correct status code for every path
- Error shape matches the one documented schema
- Pagination is cursor-based where order matters
- State-changing endpoints accept idempotency keys
- Timestamps are ISO 8601 UTC
-
Locationheader on 201 responses - Rate-limit and deprecation headers in place
Conclusion
A great API is boring in the best way: consistent, predictable, and legible to the client that has to maintain three integrations. Model resources, not procedures. Return the same-shaped errors. Version when you break, never silently. And make retries safe from day one. Your API will outlive its first frontend, its first team, and probably its first rewrite — design it like it will.
Written by
Benmalek Zohir
Founder, AI Engineer & Full Stack Developer
Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.