An API is a contract, and most of the pain in maintaining one comes from breaking that contract in small, avoidable ways. The endpoints work, but they surprise the people who call them: a field renamed without notice, a 200 that hides an error, a list route that returns 40,000 rows. The API design best practices in this guide are the habits that keep an interface predictable — so that once a developer learns one endpoint, they can correctly guess how the rest behave.
None of this is framework-specific. These principles apply whether you serve REST, RPC-style JSON, or GraphQL, and whether the backend is Python, Go, or Node. The goal is durable: an API that is safe to change and cheap to consume.
Start from the consumer, not the database
The most common design mistake is exposing your storage schema directly as your API. Your tables are optimized for writes, joins, and normalization; your API should be optimized for the questions clients actually ask. When the two are coupled, every database refactor becomes a breaking API change, and every client is forced to reassemble data your storage layer happened to split apart.
Design the response around a use case. If a mobile screen needs a user plus their current subscription, return that shape from one endpoint rather than making the client stitch together /users/42 and /subscriptions?user=42. The internal representation stays free to change; the contract stays stable. This single decision — treating the API as a deliberate interface rather than a window onto the schema — prevents more long-term grief than any other item on this list.
Name resources consistently
Consistency is worth more than cleverness. Pick one convention and apply it everywhere:
GET /orders # list
POST /orders # create
GET /orders/42 # fetch one
PATCH /orders/42 # partial update
DELETE /orders/42 # remove
A few rules that keep naming predictable:
- Use plural nouns for collections (
/orders, not/orderor/getOrder). The verb lives in the HTTP method, not the path. - Nest only to express ownership, and stop at one level.
/orders/42/itemsis fine;/orders/42/items/9/product/reviewsis a maintenance trap. Once a resource has an ID, link to it by its own top-level path. - Keep casing uniform. Whatever you choose for paths and JSON fields —
snake_caseorcamelCase— never mix them. A client that has to remember which endpoint uses which is an API that leaks your team's history.
The payoff is bounded surface area. The set of nouns in a domain is small and stable; the set of possible actions grows forever. Designing around resources keeps the interface finite.
Use status codes and methods honestly
The fastest way to erode trust in an API is to return codes that lie. Two properties are worth protecting:
- Safe methods (
GET,HEAD) never change state. Caches, browsers, and crawlers assume this — aGETthat mutates data will eventually corrupt something. - Idempotent methods (
GET,PUT,DELETE) leave the system in the same state when retried. This matters because networks fail mid-request and clients retry.POSTis neither safe nor idempotent, which is exactly why creating resources needs extra care.
Map outcomes to codes that mean what they say: 200/201 for success, 400 for a malformed request, 401 for missing or invalid credentials, 403 for authenticated-but-forbidden, 404 for a missing resource, 409 for a conflict, 422 for a well-formed request that fails validation, and 5xx only for genuine server faults. Never wrap an error in a 200 with an "error" field — every client then has to parse the body to learn whether the call worked, defeating the entire status-code system.
Make errors predictable and machine-readable
When something fails, the client needs to know three things: what went wrong, whether retrying will help, and how to tell the user. Return one consistent error shape from every endpoint:
{
"error": {
"code": "insufficient_funds",
"message": "The account balance is too low for this transfer.",
"field": "amount"
}
}
The stable, documented part is code — a machine-readable string clients can branch on. The message is for humans and may change; clients should never parse it. Keeping this shape identical across the whole API means a consumer writes error handling once instead of per endpoint. This is one of the highest-leverage API design best practices precisely because error paths are where integrations actually break in production.
Version before you break anything
You will need to change the contract. The practice that makes that survivable is versioning the API before the first breaking change, not after. The two common approaches:
- URL versioning (
/v1/orders) is explicit, trivial to route, and easy to see in logs. The trade-off is that the version leaks into every client URL. - Header versioning (
Accept: application/vnd.api+json; version=1) keeps URLs clean but is harder to test by hand and easier for clients to forget.
For most teams, URL versioning wins on operational clarity: you can see at a glance which version a request hit. Whichever you pick, treat additive changes (new optional fields, new endpoints) as non-breaking and ship them freely; reserve a version bump for removals, renames, and changed semantics. Document a deprecation window so consumers have time to migrate.
Paginate and filter every collection
Any endpoint that returns a list will eventually return more rows than anyone wants to transfer. Paginate from day one — retrofitting it later is itself a breaking change. Cursor-based pagination (an opaque token pointing at the next page) is more robust than offset/limit for large or frequently changing datasets, because inserts don't shift the window and cause skipped or duplicated rows. Offset pagination is fine for small, stable admin lists where simplicity matters more.
Expose filtering and sorting as query parameters (GET /orders?status=open&sort=-created_at) rather than minting a new endpoint for every combination. This keeps the surface small and lets clients compose the query they need. Whatever the shape, return pagination metadata consistently so clients know whether more data exists — this connects directly back to database design, since your indexes have to support the sort and filter keys you advertise.
Treat authentication as part of the contract
Authentication is not a bolt-on; it is part of the interface clients integrate against. Use a standard mechanism — bearer tokens or OAuth 2.0 for third-party access — rather than a homegrown scheme, so client libraries already know how to talk to you. Send credentials in the Authorization header, never in the URL, where they end up in logs and browser history. Mark secrets as YOUR_API_KEY in docs and examples so nobody copies a real one.
Separate the two concerns cleanly: authentication answers "who is this?" and belongs at the edge; authorization answers "may they do this?" and belongs next to each resource. Rate-limit per credential and return 429 with a Retry-After header so well-behaved clients can back off instead of hammering you. For the deeper decisions behind these choices — resource modeling, status codes, and the full request lifecycle — our REST API design guide works through them with runnable examples.
Putting it together
Good API design is not about hypermedia purity or chasing the newest spec. It is about a handful of consistent decisions applied everywhere: design for the consumer, name resources predictably, tell the truth with methods and status codes, keep one error shape, version before you break things, paginate every list, and make auth a first-class part of the contract. Each one is small on its own. Together they produce the property that actually matters — an API a developer can predict — which is the whole point of designing one deliberately rather than letting it accrete.