An API is a promise to strangers. Once someone has written code against it, every detail you chose casually — a field name, a status code, whether a list is wrapped in an envelope — becomes something you cannot change without breaking their software. That permanence is why API design deserves more thought than the hour it usually gets, and why the conventions below are worth following even when they feel fussy.
This guide covers designing a REST API that other people can use without reading your source code: how to model resources, how to use HTTP properly, how to handle errors so clients can act on them, and how to evolve without breaking anyone. It assumes you know how to build a web service but not how to design one for consumers you will never meet.
What you will learn
- How to find the right resources, and what to do when something is not a resource
- Which HTTP methods and status codes to use, and the ones people get wrong
- An error format clients can program against
- Pagination, filtering and sorting that survive large datasets
- Versioning strategies and how to change an API without breaking clients
- Idempotency, concurrency, rate limits and the operational details that matter
- What REST actually requires
- Finding the right resources
- URL design rules
- Methods and what they promise
- Status codes that mean something
- Request and response shape
- Errors clients can act on
- Pagination, filtering and sorting
- Idempotency and concurrency
- Versioning and evolution
- Authentication, authorisation and rate limits
- Documentation and contracts
- Twelve mistakes
- Designing for the operators, not just the integrators
- Frequently asked questions
1. What REST actually requires
REST is an architectural style, not a specification, which is why arguments about whether something is "properly RESTful" are usually unproductive. The constraints that matter in practice are few:
- Resources are identified by URLs. A URL names a thing, not an action.
- Representations are transferred. The client receives a representation — usually JSON — not the object itself.
- Requests are stateless. Each request carries everything needed to process it; the server holds no per-client conversation state.
- The uniform interface is respected. Standard methods, standard status codes, standard caching semantics — so intermediaries and tools work without knowing your domain.
The last point is the practical argument for following convention. When you use HTTP as intended, proxies cache correctly, retries are safe where they should be, monitoring tools classify errors properly, and every client library already knows what to do. When you invent your own semantics — a POST that returns 200 with {"error": true} — every one of those benefits disappears and every client must be taught your private rules.
2. Finding the right resources
Resource modelling is the part that determines whether an API feels obvious or exhausting. The heuristic: resources are nouns your users already talk about. If your support team says "the customer's subscription", that is a resource. If the term only exists inside your codebase, it probably should not be in the URL.
Start from the language, not the schema
Your database tables are an implementation detail shaped by normalisation, indexes and history. Exposing them directly leaks that shape to every client and locks you into it. A resource may span several tables, or expose a subset of one, and that indirection is what lets you refactor storage later.
Relationships as sub-resources
When one thing genuinely belongs to another, nest it: /customers/42/addresses. This makes ownership obvious and scopes authorisation naturally. But stop at one level. /customers/42/orders/981/lines/3/discounts is unusable, and the deeper levels have their own identifiers anyway — expose /order-lines/3/discounts and let clients link by identifier.
When something is not a resource
Some operations resist noun-shaped modelling: publish, cancel, retry, send. Two workable approaches. Model the state change as a resource — POST /orders/981/cancellation creates a cancellation, which is a real thing with a timestamp and a reason. Or, when that is contrived, use a clearly marked action endpoint: POST /orders/981/actions/cancel. The second is less pure and often clearer, and clarity wins.
3. URL design rules
| Rule | Good | Avoid |
|---|---|---|
| Plural collections | /orders | /order, mixed conventions |
| Nouns, not verbs | POST /orders | /createOrder |
| Lowercase with hyphens | /order-lines | /orderLines, /order_lines |
| Identifiers in the path | /orders/981 | /orders?id=981 |
| Filters in the query | /orders?status=open | /orders/open |
| Shallow nesting | /customers/42/orders | Four or more levels |
| No file extensions | /orders + Accept header | /orders.json |
Consistency matters more than which convention you pick. An API that is uniformly slightly unusual is easier to use than one that is conventional in half its endpoints and idiosyncratic in the rest, because the second forces the reader to check everything.
4. Methods and what they promise
| Method | Purpose | Safe | Idempotent |
|---|---|---|---|
| GET | Retrieve; must never change state | Yes | Yes |
| POST | Create, or an operation that is neither safe nor idempotent | No | No |
| PUT | Replace the whole resource at a known URL | No | Yes |
| PATCH | Apply a partial update | No | Not inherently |
| DELETE | Remove the resource | No | Yes |
Safe means no observable state change, which is why a GET that increments a counter or sends an email is a genuine bug: crawlers, prefetchers and retries will trigger it. Idempotent means repeating the identical request has the same effect as making it once — the property that makes automatic retries safe. DELETE is idempotent even though the second call finds nothing: the resulting state is the same.
PUT versus PATCH causes constant confusion. PUT replaces: fields you omit are cleared, because you sent the complete new representation. PATCH modifies: fields you omit are left alone. Most APIs want PATCH for updates, and most bugs in this area come from implementing PUT with PATCH semantics, which quietly turns a client's omission into data loss on the next full replace.
5. Status codes that mean something
| Code | Use for |
|---|---|
| 200 OK | Successful GET, PATCH, PUT, or an action returning a body |
| 201 Created | Resource created — include a Location header |
| 202 Accepted | Work queued; the response points to a status resource |
| 204 No Content | Success with nothing to return, typically DELETE |
| 400 Bad Request | Malformed syntax or unparseable body |
| 401 Unauthorized | No valid credentials — despite the name, this is authentication |
| 403 Forbidden | Authenticated, but not permitted |
| 404 Not Found | No such resource, or deliberately hidden from this caller |
| 409 Conflict | Valid request that conflicts with current state |
| 422 Unprocessable | Well-formed but semantically invalid — failed validation |
| 429 Too Many Requests | Rate limited — include Retry-After |
| 500 / 503 | Your fault; 503 specifically for temporary unavailability |
Two distinctions repay attention. 400 versus 422: use 400 when you could not parse the request at all, and 422 when you understood it perfectly and it was invalid. Clients handle these differently — one is a bug in their serialisation, the other is data the user must correct.
403 versus 404: use 403 when it is acceptable for the caller to learn the resource exists, and 404 when the existence itself is confidential. Returning 403 for another tenant's record confirms it exists, which is sometimes a meaningful information leak.
The cardinal sin is returning 200 with an error inside the body. It defeats every piece of monitoring, retry logic and error handling in the client's stack, and it means a broken integration looks perfectly healthy on your dashboards.
6. Request and response shape
Consistency here saves clients more time than any other decision. Two shapes cover almost everything:
| Shape | Convention |
|---|---|
| Single resource | Return the resource itself with no wrapper. Identifiers as strings, money as an amount in minor units paired with a currency code, timestamps in UTC with an explicit offset, and a small set of links — self, and the obvious related resources. |
| Collection | Wrap the array in an envelope carrying pagination metadata: the items under a consistent key, plus a cursor for the next page and a flag indicating whether more exist. Never return a bare top-level array — you will need somewhere to put metadata within a month. |
Beyond those two shapes, several conventions are worth adopting wholesale:
- Identifiers as strings. Numeric identifiers overflow in JavaScript above a certain size, and strings let you change generation strategy later without breaking clients.
- Money as integer minor units plus currency. Never a float. Never a bare number without a currency.
- Timestamps in UTC with an explicit offset. One format everywhere, always including the timezone.
- One casing convention. Whichever you choose, never mix — mixed casing is the most common cause of client-side field-name bugs.
- Omit or null, but be consistent. Decide whether an absent value is a missing key or an explicit null, and apply it uniformly.
- Nest related identifiers rather than embedding objects by default. Offer expansion explicitly —
?expand=customer— so clients choose their payload size.
7. Errors clients can act on
An error response has three audiences: the developer integrating, the code handling it at runtime, and the end user who will see something. Serve all three, and include five things every time.
| Error field | Why it must be there |
|---|---|
| A stable machine-readable code | Clients must branch on something that will not change when you improve the wording. One code per distinct failure mode, documented and permanent. |
| A field path | Precise enough to attach the message to the right input, including array indices for collections. Without it, a client cannot highlight the offending field. |
| A human-readable message | For the developer reading logs and, where appropriate, for the end user. Never the only signal a client can act on. |
| A request identifier | Also written to your logs, so a support conversation takes one minute rather than an afternoon. |
| A list, not a single error | Validation failures come in groups. Returning them one at a time forces users through a frustrating sequence of resubmissions. |
Never include stack traces, database fragments or internal hostnames. And never make the prose the only way to distinguish two failure modes — someone will end up matching on your English wording, and it will break the day you fix a typo.
8. Pagination, filtering and sorting
Cursor over offset
Offset pagination (?page=3&per_page=20) is familiar and breaks in two ways at scale. It gets slower as the offset grows, because the database must count past all skipped rows. And it is unstable: if a record is inserted while a client pages through, they see an item twice or miss one entirely.
Cursor pagination sends an opaque token representing "where you stopped". It stays fast at any depth and is stable under concurrent writes. The trade-off is no random access to page seventeen, which most API clients do not actually need. Offer offset pagination only where a user-facing page-number interface genuinely requires it.
Filtering
Keep simple filters simple — ?status=open&created_after=2026-01-01 — and resist inventing a query language in the query string. When clients genuinely need complex queries, a dedicated search endpoint accepting a structured body is clearer than encoding boolean logic into parameters that nobody can read.
Sorting and limits
Accept a sort parameter with an explicit direction (?sort=-created_at), allow only fields you have indexed, and reject anything else with a helpful error rather than silently ignoring it. Always impose a maximum page size, and always return the effective values so clients can tell when their request was clamped.
9. Idempotency and concurrency
Idempotency keys for unsafe operations
Network failures are indistinguishable from server failures at the client. A payment request that times out may or may not have succeeded, and retrying blindly risks charging twice. The solution is a client-generated key sent as a header with the request, and four rules for how the server treats it:
| Situation | Correct server behaviour |
|---|---|
| First request carrying a given key | Process it, and store the response against that key |
| Identical request replayed with the same key | Return the stored response — no second charge, no second record |
| Same key, different request body | Reject with a validation error; the client has a bug that would otherwise be silent and expensive |
| Key not seen again | Expire it after at least a day; keys are a retry safety net, not a permanent audit record |
Optimistic concurrency
Two clients editing the same resource will eventually collide, and last-write-wins silently discards someone's work. Return an ETag on GET and require If-Match on update. If the resource has changed since the client read it, return 412 and let them re-read and merge. This costs one header on each side and eliminates an entire class of silent data loss.
Long-running work
Anything that cannot complete within a couple of seconds should return 202 with a link to a status resource rather than holding the connection open. Clients poll that resource, or you notify them by webhook. Holding an HTTP request open for two minutes fails in a dozen ways across proxies, gateways and mobile networks.
10. Versioning and evolution
The best versioning strategy is to need it rarely. Most changes can be made without a new version if you follow one rule: add, never remove or repurpose.
| Change | Breaking? |
|---|---|
| Adding a new optional field to a response | No — clients must ignore unknown fields |
| Adding a new optional request parameter | No |
| Adding a new endpoint | No |
| Removing or renaming a field | Yes |
| Changing a field's type or format | Yes |
| Making an optional parameter required | Yes |
| Adding a value to an existing enum | Usually yes, in practice |
| Tightening validation | Yes — previously accepted requests now fail |
The enum row surprises people. Clients frequently write exhaustive switch statements, so a new status value causes failures in the field. Document from day one that clients must handle unknown enum values gracefully, and introduce new values deliberately.
Where to put the version
URL versioning (/v1/orders) is the pragmatic default: visible in logs, trivial to route, obvious in documentation, and easy to explain. Header-based versioning is theoretically cleaner but harder to debug and easier to get wrong in caches and proxies. Whichever you choose, version the whole API rather than individual endpoints — per-endpoint versions produce a combinatorial matrix nobody can reason about.
Deprecating properly
Announce with a date, mark responses with a deprecation header, instrument usage so you know exactly who is still calling, contact those consumers directly, and only then remove. Deprecations without usage data are guesswork, and removals without contact are outages you caused.
11. Authentication, authorisation and rate limits
Authentication: bearer tokens over TLS, always in the Authorization header — never in the query string, where they land in access logs, browser history and referrer headers. For machine-to-machine use, short-lived tokens obtained through a client-credentials exchange beat long-lived API keys, because rotation is automatic rather than a project.
Authorisation: enforce on every request, at the resource level, in the server. Never rely on the client not knowing a URL. Scope every query by the caller's tenant in a shared layer rather than in each handler — the one handler that forgets is the one that leaks.
Rate limiting: publish the policy, return standard headers on every response so clients can self-regulate, and return 429 rather than dropping connections. Distinguish limits per client, per endpoint and per resource — a heavy report endpoint deserves a tighter budget than a simple lookup. Four headers carry the whole conversation:
| Header | Meaning |
|---|---|
| Limit | The ceiling for the current window, so clients can pace themselves rather than discovering the limit by hitting it |
| Remaining | How many requests are left in this window |
| Reset | Seconds until the window refreshes |
| Retry-After | Sent with a 429, telling the client exactly how long to wait before retrying |
12. Documentation and contracts
An undocumented API is an internal API with extra risk. The minimum useful documentation set is small: a machine-readable specification, a quick-start that produces a successful call in under five minutes, a full reference generated from the specification, an errors page listing every code, and a changelog.
The most valuable practice is treating the specification as the contract rather than as documentation written afterwards. Generate request validation from it, run contract tests against it in continuous integration, and fail the build when the implementation and specification diverge. Documentation that is generated from something the tests enforce cannot drift; documentation written by hand always does.
Include realistic examples for every endpoint, including error responses. Developers copy examples — this is not a failure of diligence, it is how integration actually happens — so an example containing a subtly wrong field name will propagate into production systems.
13. Twelve mistakes
- Returning 200 with an error body. Breaks every layer of client and monitoring logic.
- Verbs in URLs.
/getOrdersdiscards everything HTTP already gives you. - Exposing database identifiers and schema shape. Locks you into your current storage forever.
- Offset pagination on large tables. Slow and unstable under concurrent writes.
- No idempotency on payments or creates. Duplicate charges and duplicate records.
- Money as floating point. Rounding errors in financial data are unforgivable and hard to reverse.
- Inconsistent casing or date formats. Every client writes conversion code and some get it wrong.
- Deeply nested URLs. Unreadable, and they force artificial hierarchies.
- Error messages as the only machine signal. Clients match on prose; you change the prose; they break.
- No request identifier. Turns every support conversation into an investigation.
- Unbounded responses. One client requests everything and takes the service down.
- Removing fields without deprecation. The fastest way to lose the trust of the people building on you.
14. Designing for the operators, not just the integrators
Most API guidance stops at the request and response. In practice, an API is also a production system that other people's businesses depend on, and several design decisions determine whether operating it is calm or miserable.
Every response should be traceable. Return a request identifier in a header on every response, including errors, and log it with the full context of what happened. When a customer reports that a call failed at some point yesterday, the difference between "send me the request id" and "can you describe what you were doing" is the difference between a five-minute fix and a two-day investigation. Propagate the same identifier through downstream calls so one identifier reconstructs the entire path.
Design your timeouts as a contract. Publish the maximum time any endpoint will take, enforce it server-side, and make sure it is comfortably shorter than the timeouts your clients are likely to use. An endpoint that occasionally takes ninety seconds will be abandoned by clients at thirty, retried, and will then do the same work twice — which is how a slow endpoint becomes an outage. Anything that cannot meet the published limit belongs behind a 202 and a status resource.
Make partial degradation possible. If an optional enrichment — a recommendation score, a related-items list — depends on a service that is down, return the core resource without it rather than failing the whole request. Signal the omission explicitly in the response so clients can distinguish "no related items" from "we could not check". Silent degradation is worse than failure, because nobody notices for weeks.
Instrument by consumer, not just by endpoint. Aggregate error rates hide the case where one integrator is generating every failure because they misunderstood a field. Per-consumer dashboards let you contact them before they open a ticket, which is both cheaper and a considerably better experience than the alternative.
Plan the abuse cases. Someone will request a million records, poll every second, retry aggressively during an incident, or leave a script running after they stop caring about the results. Bounded page sizes, per-consumer rate limits, and a circuit breaker that sheds load from the noisiest caller during degradation are not hostile to your users — they are what keeps the service available for the well-behaved majority.
Give yourself a way to say no gracefully. A maintenance mode returning 503 with a Retry-After and a clear message is far better than timeouts and connection resets. Clients that respect it will back off; those that do not at least receive an unambiguous signal you can point to later.
15. Frequently asked questions
Should we use REST or GraphQL?
REST fits well when resources are stable, caching matters, and consumers are diverse — public APIs, service-to-service integration, anything where HTTP caching and standard tooling carry real weight. GraphQL fits when clients need widely varying subsets of a connected graph and over-fetching is a genuine problem, typically with a small number of first-party clients. Many organisations run both: REST at the public boundary, GraphQL for their own front ends.
Do we need HATEOAS and hypermedia links?
Full hypermedia-driven APIs are rare in practice because most clients hardcode URLs regardless. However, including a small number of links — self, next page, related resources — costs little and genuinely helps discoverability and pagination. Include the pragmatic subset; skip the ideology.
How should we handle bulk operations?
A dedicated endpoint accepting an array, returning per-item results with individual statuses rather than a single pass or fail. Use 207-style partial reporting or a 200 with a results array where each entry carries its own status and error. Cap the batch size, make the whole operation idempotent with a key, and document precisely whether partial success is possible — clients must know whether to retry the whole batch or only the failures.
Should DELETE actually delete?
Usually it should mark as deleted rather than destroy, because real systems need audit trails and undo. From the client's perspective the resource is gone: subsequent GETs return 404 and it disappears from collections. Keep the actual purge as an internal process governed by your retention policy — and remember that privacy regulation may require genuine erasure on request.
How do we handle time zones?
Store and transmit instants in UTC with an explicit offset in a single standard format. Where the local time zone is semantically meaningful — a recurring calendar event, a business's opening hours — store the zone identifier alongside the instant, because offsets change with daylight saving and a stored offset silently becomes wrong. Never transmit a bare local time with no zone information.
What is the right approach to webhooks?
Sign every payload so receivers can verify it came from you. Include an event identifier and timestamp so duplicates can be detected, and state plainly that delivery is at-least-once. Retry with exponential backoff, expose delivery history and a manual replay in your dashboard, and keep payloads small with a link to fetch the full resource — payloads embedded in webhooks are stale by the time they arrive.
Should the API return everything, or let clients ask for fields?
Return a sensible default and offer explicit expansion for related resources and sparse field selection for large ones. Defaults should be small enough to be fast and complete enough to be useful without a second call. Avoid the two extremes: returning a deeply nested object graph nobody asked for, and requiring three round trips to render one screen.
How do we design an API we will not regret?
Write the client code first. Sketch what an integrator would have to write to accomplish the three most common tasks, and design the endpoints that make that code short and obvious. Most awkward APIs are awkward because they were designed outward from the database rather than inward from the caller — and that is a mistake you cannot fix once people depend on it.
Key takeaways
- Use HTTP as intended. Correct methods and status codes buy you caching, retries, tooling and monitoring for free.
- Model resources from your users' language, not your database schema.
- Errors need stable codes, field paths and a request identifier. Prose is for humans, not for branching.
- Cursor pagination, always bounded. Offset pagination breaks quietly at scale.
- Idempotency keys and ETags prevent duplicate charges and silent overwrites.
- Add, never remove. Most changes need no new version if you never repurpose a field.
The best APIs are unremarkable. Nothing surprises you, the errors tell you what to fix, and the documentation matches the behaviour. That is not a low bar — it is the result of deciding a hundred small things consistently, before anyone was depending on them.
Enjoyed this article?
Get more engineering insights from ELIVTECH — or talk to us about your project.
Get in touch