Skip to content
← All insights
API design16 min read

API design: build contracts that survive change

How to design HTTP APIs around clear resources and contracts: names, methods, validation, errors, pagination, idempotency, versioning and the evidence needed to evolve an API safely.

An API is a contract between independently changing systems

An API is not simply a controller that returns JSON. It is an agreement about names, inputs, outputs, failures, timing, authentication and what remains compatible when one side changes before the other.

The contract matters because callers build assumptions around it. A field that looks optional may be treated as required; an undocumented error response may become part of retry logic; a response order that was never promised may end up in a user interface.

Design the contract for the people and systems that will use it. That means making the normal path easy to understand and the failure path explicit enough that callers can make a safe decision.

Model resources and actions in domain language

A resource-oriented API names the things a caller works with: accounts, orders, invoices or documents. Paths should use stable domain nouns rather than database tables, framework concepts or verbs that reveal an internal workflow.

HTTP methods carry useful intent. GET reads without changing state, POST commonly creates or starts a process, PUT replaces a known representation and PATCH changes part of one. These conventions are not decoration: clients, caches and operational tools make assumptions from them.

Some business actions do not fit a simple CRUD shape. An endpoint such as POST /orders/{id}/cancel can be clearer than pretending cancellation is merely a generic field update, provided the action and its outcomes are documented.

Make the API surface reflect the domainhttp
GET  /accounts/{accountId}
GET  /accounts/{accountId}/orders
POST /orders
POST /orders/{orderId}/cancel

// Avoid endpoints such as:
POST /doOrderThing
GET  /orderController/getOrder

Make request validation visible at the boundary

Validate untrusted input before it reaches domain behaviour. Check the request shape, required fields, formats, ranges and cross-field rules at the API boundary, then translate valid transport data into a command the application can understand.

Validation is more than rejecting malformed JSON. A value can have the right type while still violating a business rule: an end date can precede a start date, an amount can be negative or a user can request an operation they are not permitted to perform.

Return errors that help a caller correct the request without leaking internal implementation details. Stable error codes are often more dependable for a program than prose, while a concise human-readable message helps an operator or product team diagnose the issue.

Return a clear, stable validation responsejson
{
  "code": "invalid_request",
  "message": "One or more fields are invalid.",
  "errors": [
    { "field": "amount", "code": "must_be_positive" }
  ]
}

Use status codes to describe the outcome

Status codes communicate the broad outcome while the response body provides the detail. A successful read normally returns 200, a created resource returns 201 with a Location where useful, and an accepted long-running request can return 202 with a way to inspect progress.

Client mistakes are not server failures. Use 400 for an invalid request, 401 when authentication is required or invalid, 403 when an authenticated caller is not allowed, 404 when a resource is not available to that caller and 409 for a meaningful state conflict.

Do not use 200 for every response merely because the body contains an error field. It forces every client and intermediary to invent its own interpretation and hides failures from ordinary HTTP tooling.

Design collections for filtering and pagination

A collection endpoint eventually needs bounds. Returning every order or event works in a development database and fails once a customer has years of history. Set a default page size, enforce a maximum and make the ordering explicit.

Offset pagination is simple for small, stable lists. Cursor pagination is often more reliable when new data arrives while a caller is paging, provided the cursor is opaque and tied to a deterministic ordering such as created time plus identifier.

Filters and sort fields are part of the contract. Whitelist supported values, document whether text matching is exact or partial and avoid exposing arbitrary query syntax that couples callers to the database implementation.

Return a bounded collection with a continuation cursorjson
{
  "items": [
    { "id": "ord_123", "status": "paid" }
  ],
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA5LTAxVDEwOjAwOjAwWiIsImlkIjoib3JkXzEyMyJ9"
}

Make retries safe for state-changing requests

Networks make a request outcome uncertain. A client can send a POST, the server can complete it, and the connection can fail before the response arrives. Retrying without protection can create a second order, payment or account.

For an operation with an externally visible effect, accept an idempotency key from the client. Store the key with the request fingerprint and final response. A retry with the same key returns the recorded outcome rather than doing the work again.

The key must have a defined scope and retention period. Reject reuse of one key for a different request body; otherwise an accidental client bug can receive a successful response for work it did not ask to perform.

Make a creation request retry-safehttp
POST /orders
Idempotency-Key: 01J...
Content-Type: application/json

{ "accountId": "acc_123", "amount": "24.00" }

Version with restraint and compatibility in mind

A new optional response field is usually compatible. Removing a field, changing its meaning, tightening accepted input or changing an error shape can break callers even when the URL remains the same.

Prefer additive evolution where possible: introduce a new field, support an old and new input during a transition, and announce a removal with a measured deprecation period. Versioning a whole API path can be appropriate for a genuinely incompatible contract, but it creates two surfaces to support.

Compatibility needs evidence. Know which clients use the API, log deprecated-field use where possible and give consumers a migration path before forcing a change.

Authenticate, authorise and limit deliberately

Authentication identifies the caller; authorisation decides what that caller may do. Keep those decisions explicit near the boundary and avoid assuming that possession of an identifier grants access to the corresponding resource.

Apply rate limits to protect a service and its dependencies. The useful limit is tied to a caller, credential or operation rather than merely a broad IP address where legitimate traffic may be shared. Return a useful response and retry guidance when a caller reaches a limit.

Do not put secrets, tokens or sensitive personal data in URLs. Query strings are commonly retained in browser history, access logs and monitoring systems. Send credentials through the appropriate header and redact sensitive fields from logs.

Document and test the contract as it changes

An OpenAPI description, examples and a concise guide can make an API easier to adopt, but generated documentation is only useful when it matches deployed behaviour. Treat the specification as part of the delivery artefact, reviewed and changed with the implementation.

Test important contract cases at the HTTP boundary: valid requests, invalid input, permissions, missing resources, pagination, retries and the error shapes callers rely on. Consumer-driven contract tests can give early warning when independently deployed clients and services drift apart.

Observe the API in production by operation: latency, response status, error code, rate-limit outcomes and dependency failures. A reliable contract is one that can be explained when a caller reports that it no longer behaves as expected.

  • Name resources using stable domain language
  • Validate input and return machine-readable error codes
  • Use status codes that match the outcome
  • Bound and order every collection response
  • Protect state-changing requests with idempotency where retries are possible
  • Evolve contracts additively and measure use before removal
  • Test and observe the API at its public boundary