Back to Insights
Software

API Design Principles That Stand the Test of Time

APIs outlive the code that calls them. A practical guide to designing HTTP APIs that stay stable, intuitive, and maintainable as your product scales.

S5 Labs TeamOctober 14, 2025

An API is a promise. Once external consumers depend on it, every URL, every field name, and every status code becomes a contract that is expensive to change. Internal code can be refactored on a Friday afternoon. A public API change requires migration guides, deprecation timelines, and uncomfortable conversations with partners who built their businesses on your endpoints.

Getting the design right early does not mean predicting the future. It means establishing patterns flexible enough to accommodate the future without breaking the present. The principles below are drawn from studying APIs that have aged well — Stripe, GitHub, Twilio, Shopify — and from the hard lessons of maintaining APIs that did not.

APIs Are Contracts, Not Implementation Details

The most common mistake in API design is treating your HTTP endpoints as a thin skin over your database. Your consumers do not care that you store orders and line items in separate tables with a foreign key relationship. They care about placing an order.

Design your API around domain concepts as consumers understand them, not around the internal structure of your system. If your users think in terms of “subscriptions,” expose a /subscriptions resource even if internally that concept spans three microservices and five database tables. The moment your API shape mirrors your architecture, every internal refactor becomes a breaking change for every consumer.

This separation also gives you room to evolve your implementation. Teams that couple their API to their database schema discover that they cannot migrate to a new data model, split a service, or optimize a query path without coordinating changes with every client. Teams that treat the API as an independent contract can change anything behind it.

Naming and Consistency

Consistency is the single highest-leverage quality in API design. A predictable API reduces the need for documentation because consumers can guess correctly. This is a direct application of the principle that developer experience is a business metric — every inconsistency in your API costs every consumer time, and that cost multiplies with adoption.

Establish Patterns and Follow Them Relentlessly

Use plural nouns for collection endpoints. If you have /users, do not also have /order — make it /orders. Use consistent casing everywhere. Pick snake_case or camelCase for JSON fields and stick with it across every endpoint. Stripe uses snake_case throughout. GitHub uses snake_case. Both are easy to work with because they never surprise you.

URL patterns should be predictable:

GET    /v1/customers              # List customers
POST   /v1/customers              # Create a customer
GET    /v1/customers/:id          # Retrieve a customer
PATCH  /v1/customers/:id          # Update a customer
DELETE /v1/customers/:id          # Delete a customer
GET    /v1/customers/:id/invoices # List a customer's invoices

Nested resources should follow a natural hierarchy, but avoid nesting more than two levels deep. /customers/:id/invoices is clear. /customers/:id/invoices/:invoice_id/line_items/:line_item_id/adjustments is a sign that you need a top-level /adjustments resource instead.

Name Things for Clarity, Not Brevity

created_at is better than ts. billing_address is better than addr. Your API will be read thousands of times by people who did not design it. Optimize for their comprehension, not for saving a few characters on the wire.

Versioning Strategies

Every non-trivial API will eventually need to introduce breaking changes. Your versioning strategy determines how painful that process is.

URL Path Versioning

The most common approach: prefix your routes with /v1/, /v2/, and so on. GitHub uses this pattern. It is simple, visible, and easy to route at the infrastructure level. The downside is that a version bump implies the entire API has changed, even if only one endpoint is affected. It also means maintaining parallel implementations of every unchanged endpoint across versions.

Header-Based Versioning

Clients specify a version via a request header, such as Accept: application/vnd.myapi.v2+json. This keeps URLs clean but makes the API harder to explore in a browser and harder to debug — you cannot tell from a URL alone which version a request targets.

Date-Based Versioning (The Stripe Model)

Stripe takes a different approach entirely. Each API key is pinned to the version that was current when the key was created. New versions are identified by date (2025-09-15), and Stripe maintains a changelog of every breaking change between versions. Clients upgrade on their own schedule by passing a Stripe-Version header.

This model works exceptionally well because it decouples the release of changes from the consumer’s adoption timeline. It requires significant engineering investment — Stripe maintains compatibility layers that translate between versions — but for APIs with many external consumers, the reduced coordination cost is worth it.

For most teams, URL path versioning is the pragmatic choice. Reserve date-based versioning for when your API is a core product, not just a feature. Whichever approach you choose, favor proven, well-understood patterns over clever custom schemes — your consumers will thank you.

Error Handling Done Right

Every API returns errors. The question is whether those errors help the consumer fix the problem or send them searching through support tickets.

A well-structured error response includes three things:

{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_missing",
    "message": "The 'email' field is required when creating a customer.",
    "param": "email",
    "doc_url": "https://docs.example.com/api/customers#create"
  }
}

A machine-readable type or code that clients can switch on programmatically. Do not make consumers parse human-readable strings to determine what went wrong.

A human-readable message that a developer can read in their logs and understand immediately. “Validation failed” is useless. “The ‘email’ field is required when creating a customer” tells them exactly what to fix.

Context — which parameter failed, which resource was not found, what the valid options are. The more context you provide, the fewer support tickets you receive.

Equally important: never leak internal details. Stack traces, database error messages, internal service names — none of these belong in an API response. They are security risks and they confuse consumers who cannot act on them anyway.

Use HTTP status codes correctly. 400 for client mistakes, 401 for missing authentication, 403 for insufficient permissions, 404 for missing resources, 422 for valid syntax but unprocessable content, 429 for rate limiting, 500 for your mistakes. Be precise. A 400 when you mean 422 costs someone an hour of debugging.

Pagination, Filtering, and the Long Tail

Any endpoint that returns a list will eventually return a large list. Design for this from the start.

Cursor-Based Pagination

Offset-based pagination (?page=2&per_page=50) is intuitive but breaks down at scale. Counting rows to calculate offsets is expensive on large tables, and results shift as new records are inserted — consumers can miss items or see duplicates between pages.

Cursor-based pagination avoids both problems. The response includes an opaque cursor that the client passes to fetch the next page:

{
  "data": [...],
  "has_more": true,
  "next_cursor": "cus_abc123"
}

The cursor encodes enough information for the server to efficiently resume from the right position. Stripe, Shopify, and Twilio all use cursor-based pagination for this reason.

Filtering and Partial Responses

Establish consistent filtering patterns early. A common convention is using query parameters that match field names: GET /orders?status=shipped&created_after=2025-01-01. Document which fields are filterable and use the same parameter naming across all endpoints.

For bandwidth-sensitive clients, consider supporting field selection: GET /customers/123?fields=id,name,email. This is particularly valuable for mobile consumers and high-frequency integrations. For a deeper treatment of how these API design choices interact with broader system connectivity, see our guide on integration patterns that scale.

Evolution Without Breakage

The safest changes to an API are additive ones. Adding a new field to a response, adding a new optional parameter to a request, adding a new endpoint — none of these break existing consumers. Build your API with additive evolution as the default mode.

Removals are where things get dangerous. Removing a field, renaming a parameter, changing the type of a value, altering the meaning of a status code — all of these can break consumers silently. A client that expected a string and now receives an integer may not crash immediately. It may corrupt data quietly for weeks before anyone notices.

Deprecation as a Process

When you must remove or change something, treat deprecation as a formal process with defined stages:

  1. Announce — document the deprecation, add a Sunset header to affected endpoints, notify consumers directly if possible.
  2. Overlap — maintain both the old and new behavior for a defined period. Log usage of deprecated features so you can track adoption of the replacement.
  3. Retire — remove the old behavior only after usage has dropped to an acceptable level or the sunset date has passed.

The Sunset HTTP header (RFC 8594) is an underused tool for communicating deprecation timelines programmatically. A response that includes Sunset: Sat, 01 Mar 2026 00:00:00 GMT gives consumers a machine-readable deadline that their tooling can flag automatically.

Design for the Changes You Cannot Predict

Use envelopes for your responses ({ "data": {...} } rather than returning raw objects) so you can add metadata later without restructuring. Use strings for identifiers even if they are currently numeric — migrating from integer to UUID is easier when consumers already treat IDs as opaque strings. Include a type field on polymorphic resources so clients can handle new resource types gracefully.

These small decisions compound. An API built with evolution in mind can grow for years without forcing consumers through painful migrations. An API built without that foresight accumulates breaking changes like technical debt — quietly at first, then all at once. The same principle applies at the architectural level: right-sizing your service boundaries determines how cleanly your APIs can evolve over time.

The best API is one your consumers forget about because it simply continues to work. That reliability is not accidental. It is the result of treating design decisions as commitments and making those commitments carefully.

Want to discuss this topic?

We'd love to hear about your specific challenges and how we might help.