API Design Best Practices: How to Build APIs That Last for Years

Diagram of one API at the centre of mobile, web, partner and script clients, with labels for idempotency and pagination

API design best practices are the decisions that keep an API stable, predictable and safe to use as it changes, fails and grows. They cover how you shape URLs, use HTTP methods, handle retries, version your contract, paginate lists and report errors. These choices separate a fragile API from one developers trust for years.

This matters because one careless change can break thousands of apps overnight, written by people you have never met. This guide walks through each decision in the order you will face it, with concrete examples of what goes wrong and why each fix works.

In short
  • Treat every endpoint as a public contract: clients depend on everything they can observe.
  • Use nouns in URLs and let HTTP methods carry the action.
  • Make writes idempotent, using idempotency keys for POST, so retries are safe.
  • Version from day one, and paginate every list before launch, preferring cursors when data changes often.
  • Design errors with accurate status codes, a stable code field and a message that explains the fix.
  1. 0:00Intro
  2. 0:22Your API is a promise
  3. 1:22Every behaviour becomes a dependency
  4. 2:13Design resources, not actions
  5. 3:13Let the verbs do the work
  6. 4:09The network will fail you
  7. 5:00Make repeats safe
  8. 5:59Version from day one
  9. 7:02Change without breaking anyone
  10. 7:57Paginate everything
  11. 8:53Cursors beat offsets when data shifts
  12. 9:53Follow the cursor to the end
  13. 10:47Errors are features
  14. 11:50A clear error message

Why is an API a promise to its clients?

The moment someone else calls your API, it stops being only your code and becomes their dependency. A useful comparison is a power socket in a wall: thousands of devices are built to fit that exact shape, and nobody expects it to change overnight. Your endpoints work the same way. Mobile apps, partner integrations and scripts are all shaped around them.

Consider a common mistake. A backend developer renames user_name to username because it looks cleaner. Tests pass, the deploy goes out, and within minutes every mobile app still on the old release crashes at login. You can redeploy your server in minutes, but you cannot redeploy your users' phones. Some people never update, so old app versions keep calling your API for a very long time.

There is a deeper problem too. Clients do not only depend on what you documented; they depend on everything they can see. Engineers call this Hyrum's Law: with enough users, every observable behaviour will be depended on by somebody. Field order, default sorting, even the exact wording of an error message can end up in someone's code.

  • One renamed field can crash every client that reads it.
  • Developers choose APIs that stay stable; surprise breakage sends them elsewhere.
  • Expose less, deliberately: anything in a response may need support for years.

Design resources, not actions

A clean contract starts with the shape of your URLs. Put nouns in the path and let HTTP methods say what you want to do. Think of a URL as an address rather than a command: it tells you where something lives, and the method tells the server what to do with it.

Action-style URLs multiply endlessly: createOrder, cancelOrder, getOrderById, updateOrderStatus. The resource style uses one noun, orders, and the same few verbs work everywhere. That predictability is the point. A developer who has used /orders can guess /customers and /invoices without opening the docs, which means fewer support tickets and faster integrations.

Relationships can nest, such as /orders/42/items for the items that belong to order 42. Keep nesting to one or two levels. Long, deeply nested paths are hard to read, hard to cache and fragile to maintain.

  • Predictable by default: learn one resource and you can guess the rest.
  • Nest only to show ownership, and keep it shallow.
# Action-style: grows forever
POST /createOrder
POST /cancelOrderById
# Resource-style: predictable
POST   /orders
DELETE /orders/42

What does each HTTP method promise?

Once your URLs are nouns, the verbs carry the meaning. You do not need to invent your own vocabulary, because each HTTP method comes with built-in promises that browsers, proxies and client libraries already understand. Breaking those promises causes real damage.

GET only reads. It should never change anything, which is why caches and browsers feel free to repeat it or store the result. A GET that deletes data is a disaster waiting for the first crawler. POST creates something new or triggers work, and it is not repeat-safe by default: send it twice and you may get two orders, two accounts or two charges.

PUT replaces the whole resource with what you send, so sending it again gives the same result. PATCH changes only the fields you send; it is lighter, but you have to think harder about repeats. DELETE removes a resource, and deleting twice leaves the same end state, even if the server answers differently the second time.

  • GET: no side effects, safe to cache and repeat.
  • POST: creates or triggers work; the one to watch.
  • PUT: full replacement. PATCH: partial update.
  • DELETE: same end state however many times it runs.

Idempotency: how to make API retries safe

The network is unreliable. Requests time out, connections drop and clients retry, so the same request will reach your server more than once. A timeout tells the client nothing about what happened. The request may never have arrived, or it may have succeeded and only the reply was lost.

Picture a user tapping Pay. The server receives the request, talks to the bank and charges the card. Then the user walks into a lift and the success response never arrives. The app sees a failure and sensibly retries. If your API is not built for that, the card is charged twice, leaving an angry customer and a refund to process.

The fix is idempotency: an operation that produces the same result whether it runs once or ten times. For POST, the standard technique is an idempotency key. The client generates a unique key for each operation and sends it in a header. The server stores each key with its response, so a retry with the same key gets the original answer back instead of a second charge.

PUT and DELETE are idempotent by definition, but you can still break that. If a PUT handler increments a counter or sends an email every time, repeats are no longer harmless. Keep hidden side effects out, reject a reused key that arrives with a different body, and expire old keys.

  • Assume every request may arrive more than once.
  • Send a unique key per POST operation and store it with the response.
  • Reject reused keys with a different body; expire old keys.
POST /payments
Idempotency-Key: 7f3a-91c2

# Same key again? Server returns the
# stored result. No second charge.

API versioning: why you should start on day one

Even a carefully designed API will need to change, and changing a live contract without versioning breaks real clients with no way to undo it. Versioning lets old and new clients live side by side: old clients keep using v1, new clients adopt v2, and nobody wakes up to a broken app.

There are two popular styles. URL versioning puts the version in the path, like /v1/orders, which is visible at a glance and easy to route, test and cache, though it clutters URLs over time. Header versioning puts the version in the Accept or a custom header, keeping URLs clean but making the version harder to spot and debug. Both work; consistency matters more than the choice.

Start on day one because your earliest, often most important clients otherwise end up on an unversioned contract, and retrofitting is messy. Know what counts as breaking: removing, renaming or changing the type of a field breaks clients, while adding optional fields does not.

How to change an API without breaking clients

You do not want a new version for every small tweak. The guiding idea is to grow your API by adding things rather than changing them. Need a phone number on the user? Add an optional field. Well-behaved clients ignore fields they do not recognise, so nobody breaks and no new version is required.

When something truly must go, deprecate it publicly. Flag it in the docs, send deprecation headers in responses and publish a sunset date. Track which clients still call the old version and contact them directly. Then retire it only when your metrics show traffic has dropped close to zero, not just because a date passed. Nobody should learn about a removal from a production outage.

  • Add, don't change: new optional fields and endpoints fit the current version.
  • Announce deprecation in docs and responses, with a sunset date.
  • Sunset with data, never by surprise.

Why you should paginate every list endpoint

Any endpoint that returns a list will eventually return a huge one. The ten orders in your test database might be tens of thousands for your biggest customer. Without pagination, one request tries to load them all, and the damage spreads: the database scans too much, the server's memory spikes, the network chokes and the mobile app freezes while parsing.

Always return a page by default and cap the page size clients can request. If a client asks for a million rows, it gets your maximum. Never let a single request decide how much work your server does. Add pagination before launch, too: if v1 returns a bare array, wrapping it in a paged object later is a breaking change. Ship the paged shape now, even if the first page holds everything.

Cursor vs offset pagination: which should you use?

Offset pagination says skip the first forty rows and give me twenty, using parameters like ?page=3 or ?offset=40. It is simple and lets users jump to any page. Cursor pagination says give me twenty items after this specific one, as in ?after=abc123. It gives up random jumps, but stays correct and fast at any depth.

Offsets break when data shifts. You load page one of a feed, someone posts something new, and everything moves down by one, so page two starts with an item you already saw. Deletes do the opposite and silently skip items. Offsets also slow down on deep pages because the database still walks past every skipped row.

Cursors work as a chain. The client requests the first page and receives items plus a next cursor, sends that cursor back for page two, and continues until the next cursor comes back empty. The cursor usually encodes the last item's sort value and ID, often base64-encoded. Keep it opaque so clients never parse it, and you can change it internally later. Sort by something unique and stable, such as creation time with ID as a tiebreaker, so items sharing a timestamp are not lost between pages.

API error handling: how to design useful errors

Developers often spend more time with your errors than with your happy path, so errors are part of your design. Status codes come first because tools rely on them. A 4xx means the client must fix something and should not retry as-is; a 5xx means the server failed, so try again later. Returning 200 with an error inside fools monitoring tools and retry libraries.

Be precise. 401 means the server does not know who you are; 403 means it knows and you are not allowed; 409 signals a conflict. Codes like 400, 404 and 422 each tell a different story too. Return every error in the same shape across all endpoints so clients can write one handler.

A good error body serves two audiences: a stable code for programs, a readable message for humans, the field that caused it and a request ID support can use to find the exact log line. Clients will branch on the code, so never rename it; messages can be improved freely. Say how to fix the problem, such as quantity must be between one and a hundred, but never leak stack traces or internal details, since that is a security risk.

{"error": {
  "code": "card_declined",
  "message": "Card declined by issuer.",
  "field": "payment_method",
  "request_id": "req_8Hk2" }}

Key takeaways

  • Every observable behaviour of your API can become someone's dependency.
  • Nouns in URLs plus standard HTTP methods make an API predictable.
  • Idempotency keys turn retries from a risk into a safety net.
  • Version from day one and evolve by adding optional fields, not changing existing ones.
  • Paginate every list before launch; use stable, opaque cursors for changing data.
  • Treat error codes as part of the contract and messages as guidance for humans.

Frequently asked questions

What is idempotency in API design?

An idempotent operation produces the same result whether it runs once or many times. It matters because networks fail and clients retry, so the same request can reach your server more than once.

How do idempotency keys work for POST requests?

The client generates a unique key per operation and sends it in a header. The server stores the key with its response and returns that original response for any retry with the same key, rejecting reused keys that come with a different body.

Should I use URL versioning or header versioning?

Both work. URL versioning such as /v1/orders is easy to see, route and cache, while header versioning keeps URLs clean but is harder to debug. Picking one and applying it consistently matters more than which one you choose.

What counts as a breaking change in an API?

Removing a field, renaming one or changing its type breaks clients. Adding new optional fields or new endpoints generally does not, because well-behaved clients ignore fields they do not recognise.

Why is cursor pagination better than offset pagination?

Offset pagination can repeat or skip items when data is added or deleted while someone pages through it, and it slows down on deep pages. Cursor pagination stays stable and fast at any depth, though it gives up jumping to an arbitrary page number.

Why shouldn't an API return 200 OK with an error message?

Monitoring tools and retry libraries rely on status codes to understand what happened. A 200 with an error inside makes failures look like successes, so use 4xx for client mistakes and 5xx for server failures.

Watch the full video on YouTube →

Souy Soeng

Souy Soeng

Hi there 👋, I’m Soeng Souy (StarCode Kh)
-------------------------------------------
🌱 I’m currently creating a sample Laravel and React Vue Livewire
👯 I’m looking to collaborate on open-source PHP & JavaScript projects
💬 Ask me about Laravel, MySQL, or Flutter
⚡ Fun fact: I love turning ☕️ into code!

Post a Comment

CAN FEEDBACK
Ad