Insights14 min read

A Standard Checklist for Exposing a REST API

API designArchitectureStandards

This is a checklist for any application that exposes a REST API to other systems. Each section states the problem first, then the standard that solves it. Work through it before the first endpoint ships, because most of these decisions are expensive to reverse once a caller depends on them.

MUST and MUST NOT are used as defined in RFC 2119.

Authentication

Problem. An API exposed to other systems has to know who is calling. Shared API keys get copied into config files, emailed, committed to repositories and never rotated. Worse, if every service that validates a credential also holds the secret used to create it, then every one of those services can mint credentials. A read-only reporting service becomes a way to issue tokens.

Solution. OAuth 2.0 Client Credentials, issuing JWTs signed with RS256.

The value of asymmetric signing is the split it creates. The authorization server holds the private key and is the only thing that can sign a token. Every other service holds only the public key, and can verify a token without being able to produce one. A compromised downstream service leaks nothing that lets an attacker forge access.

That property is what makes this scale. A new service added next year needs no shared secret, no key exchange and no coordination. It fetches the public keys and starts validating. ES256 gives the same guarantee with smaller keys if the ecosystem supports it, but the model is identical.

Public keys are published through a JWKS endpoint so rotation does not require redeploying every consumer. Keys rotate annually at minimum and immediately on compromise, with the old and new key both present in JWKS through the overlap window.

TLS 1.2 or higher is enforced at the edge. Credentials MUST NOT appear in URLs.

A failed authentication returns 401 with a generic message. The response MUST NOT reveal whether the token was expired, malformed or signed by an unknown key. Each of those is a useful hint to someone probing the endpoint.

Access tokens

Problem. A long-lived token that leaks is usable for months. Short-lived tokens fix that, but a client written carelessly then requests a fresh token before every single API call, which turns the authorization server into the busiest service in the estate.

Solution. A 60 minute lifetime, returned as expires_in, with a caching obligation on the client.

Clients MUST cache the token in memory and renew it 30 seconds before expiry. A token request per API call is a defect, not a style choice. Tokens are stateless and never persisted by the API.

Refresh tokens are not issued. Refresh tokens exist so a human is not asked to log in again, and there is no human in a server-to-server call. Adding them only widens the attack surface. Clients re-authenticate at the token endpoint.

One filter validates signature, issuer, audience and expiry. Controllers never validate tokens. Tokens MUST NOT be logged in full, only the last 4 characters, and MUST NOT appear in URLs, query strings or error bodies.

Client credentials

Problem. Credentials that never expire stay valid long after the developer who created them has left. Credentials that do expire cause an outage at 3am unless rotation can happen without downtime.

Solution. A 90 day expiry, with overlapping validity during rotation.

Rotation is self-service through an endpoint. The old and new secret are both valid for 7 days, so a client can deploy the new one and retire the old one on its own schedule. The server drives the reminders, notifying the technical contact 14, 7 and 1 day before expiry.

Expired secrets are rejected with no grace period. A grace period is just a longer expiry that nobody documented.

Authorization

Problem. Authenticated is not authorized. When permission checks live inside business logic they get written once per endpoint, and the one that gets forgotten is the one nobody notices until it is exploited.

Solution. Scopes carried as JWT claims, enforced centrally.

Scopes are named resource:action, such as orders:read and orders:write. Read and write are independent scopes. Write does not imply read, because the ability to submit a change is not the ability to see everything.

Enforcement happens in a central filter. Controllers declare the scopes they require by annotation. There are no permission checks inside business logic. Scopes are assigned at onboarding and stay static until the agreement changes.

Denials are specific. 401 for a missing or invalid token. 403 for a valid token with insufficient scope. 404 for a resource belonging to another tenant, for the reason in the next section.

Tenant isolation

Problem. In a multi-tenant API, a single missed filter returns one customer's data to another. It is the failure that ends commercial relationships, and it is usually one forgotten WHERE clause.

Solution. Take the tenant only from the signed token, and enforce it in every layer independently.

If the backend already enforces tenant scoping, these checks cost a few redundant comparisons. If it does not, and they were skipped, the cost is an incident affecting every customer at once.

The tenant identifier comes only from the signed token claim. Never from a header, path, query string or body. Anything the caller can set is not an identity.

Isolation is layered. A filter extracts the tenant, a context object carries it, and the service and repository layers apply it to every query. No single layer is trusted on its own.

If the context is a ThreadLocal, it MUST be propagated explicitly across async and executor boundaries. A ThreadLocal that silently fails to cross a thread is worse than not having one, because the code reads as though it is protected.

Add a compile-time guard. Downstream client methods take the tenant as a mandatory argument, and query filter objects require it at construction. Code that omits the tenant does not compile, which is stronger than code review.

Verify the tenant on the way back as well. Every downstream response is checked against the caller, which catches filtering bugs in the downstream service instead of trusting it.

Cross-tenant denial is 404, never 403. A 403 confirms the resource exists, which is half of what an attacker wanted to learn.

Every endpoint MUST have a cross-tenant integration test, and CI fails without one.

Versioning

Problem. A field renamed on a Tuesday breaks every integration that afternoon. Callers cannot upgrade on demand, and some of them will not upgrade for a year.

Solution. Major version in the URI, additive changes only within a version, and dated deprecation.

Paths carry the major version: /v1/, /v2/. New fields and new endpoints are additive and ship inside a major version. Breaking changes require a new major version. Clients MUST ignore unknown fields, which is what keeps additive changes genuinely additive.

Deprecation is announced with the Deprecation header (RFC 9745) and Sunset header (RFC 8594), with documented dates and a minimum of 6 months notice. Version N and N-1 run side by side. A published major version is never broken in place.

Naming and data formats

Problem. Inconsistent naming forces every integrator to check the docs for every field. Money represented as a floating point number loses precision, and money represented as minor units breaks the moment a currency does not use two decimal places. Sequential database identifiers tell a caller how many records exist and invite enumeration.

Solution. Fix the conventions once and lint them in CI.

Use snake_case for paths, parameters and JSON fields. Resources are plural lowercase nouns, such as /v1/orders and /v1/payment_methods. Nesting stops at two levels; beyond that, flatten the path and filter with query parameters.

Identifiers are opaque and type-prefixed, like order_abc123. Database identifiers MUST NOT be exposed.

Money is an object holding a decimal string and an ISO 4217 currency code.

{"amount": "150.00", "currency": "USD"}

Never a JSON number, because client parsers lose precision. Never a minor-unit integer, because the exponent varies by currency and someone will assume it is always two.

Timestamps are RFC 3339 in UTC, such as 2026-05-24T14:05:00Z, and field names end in _at.

Lint the OpenAPI spec in CI with a tool like Spectral. A standard without enforcement drifts back to whatever each developer prefers.

Methods and status codes

Problem. When teams choose status codes independently, one endpoint returns 200 with an error in the body, another returns 500 for a validation failure, and clients end up parsing strings to work out what happened.

Solution. Fix the semantics once and apply them everywhere.

GET is safe and idempotent. POST creates or performs an action. PUT is a full replace. PATCH is a partial update using JSON Merge Patch (RFC 7396). DELETE is idempotent, and soft delete is preferred.

On success: 200 for read and update, 201 for create with a Location header, 202 for accepted asynchronous work, 204 for delete.

Client errors are 400, 401, 402, 403, 404, 409 and 429. Reserve 402 for business rejections, such as insufficient funds or a suspended account, so that a caller can distinguish a malformed request from a request that was understood and refused. Do not use 422; 400 already covers semantic validation, and two codes for one condition means neither gets applied consistently.

Server errors are 500, 502, 503 and 504.

Errors

Problem. A stack trace returned to a caller leaks internal structure. A generic "something went wrong" with no correlation identifier leaves on-call with nothing to search. Most APIs pick one failure or the other.

Solution. Two separate vocabularies, one for the caller and one for the operator.

Status codes serve the client. On-call needs detail. Mixing the two either leaks internals or loses the signal that would have made the incident short.

Every 4xx and 5xx returns application/problem+json (RFC 9457, which obsoletes RFC 7807).

External codes are stable snake_case strings such as account_suspended, validation_failed and rate_limited. They are part of the API contract and cannot change freely. Internal codes are SCREAMING_SNAKE_CASE, granular, and free to change. They are logged and used as metric labels, and never returned.

Maintain one table mapping an internal exception and downstream error to an HTTP status, an external code and an internal code. The API team owns it. Logs carry the internal code, the exception class, the downstream service, latency and the request identifier.

HTTP/1.1 402 Payment Required
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/account_suspended",
  "title": "Account is suspended",
  "status": 402,
  "code": "account_suspended",
  "detail": "Debits are not permitted on this account.",
  "request_id": "req_7f3a9c21"
}

Pagination

Problem. Offset pagination skips or duplicates rows whenever records are inserted while a client is paging through results, and it gets slower the deeper the caller goes. Both problems appear in production and neither appears in testing.

Solution. Cursor-based pagination with an opaque token.

The response returns a data array plus a page_info object holding next_page_token and has_more. Default page size is 25 and the maximum is 100, and a request above the maximum returns 400 rather than silently clamping.

Include total_count only when it is cheap to compute, and state that clients MUST NOT depend on it. Every endpoint needs a stable, deterministic sort with the resource identifier as tie-breaker. Without a tie-breaker the cursor is not stable.

Idempotency

Problem. A client sends a request, the connection drops before the response arrives, and the client retries. The first request succeeded. The action has now happened twice, and neither side knows.

Solution. A mandatory idempotency key on every state-changing request.

An Idempotency-Key header MUST be sent on every POST and PATCH. A missing key returns 400. Keys are retained for a minimum of 24 hours.

A key is in one of three states. IN_PROGRESS, COMPLETED or FAILED. A retry on COMPLETED replays the stored response. A retry on IN_PROGRESS returns 409. FAILED is terminal, and the client retries with a new key.

Store a SHA-256 fingerprint of the canonicalized body alongside the key. The same key sent with a different body returns 409 and never a replayed response, because replaying the wrong response is worse than refusing.

Keys live in a persistent store. If that store is unavailable, reject with 503. The API fails closed and never processes a request without idempotency protection, because the alternative is processing duplicates during exactly the incident that caused the retries.

Models and mapping

Problem. Returning an internal entity directly is the fastest way to ship an endpoint. It is also the moment every internal field name becomes a public contract that cannot be changed without a major version.

Solution. Separate external DTOs from internal models, with generated mapping between them.

External DTOs and internal models are separate classes with no shared types across the boundary. Mapping is generated at compile time, so a mapping mistake fails the build rather than production. Validation runs on inbound DTOs before mapping.

For PATCH, use a wrapper that distinguishes an absent field, meaning no change, from an explicit null, meaning clear the value. Split requests into a create shape for POST and an update shape for PATCH.

Money, timestamps, enums and identifiers each get one central mapper. DTOs are owned per major API version. The internal model stays unversioned and the mappers absorb the difference.

Rate limiting

Problem. One client with a retry loop saturates the API and degrades service for everyone else. Without published limits, a well-behaved client has no way to know it is close to the edge until it is rejected.

Solution. Token bucket per client per endpoint class, with the current state returned on every response.

A token bucket allows legitimate bursts without allowing sustained abuse. Sensible starting defaults are 100 reads per second with a burst of 200, 20 writes per second with a burst of 50, and 5 authentication requests per second with a burst of 10. Per-client tiers change by configuration, not deployment.

Return RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset on every response, not only on rejection, so a client can throttle itself before it is rejected. A rejection returns 429 with Retry-After and a problem+json body.

Enforce primarily at the API gateway, with a backstop in the application tier. Use identical limits in every test environment. A limit that is never hit in testing will be hit in production.

Logging and tracing

Problem. A caller reports that a request failed an hour ago. Without a correlation identifier there is no way to find it. With unrestricted body logging, the search finds it along with everyone's personal data, now retained for a year.

Solution. A correlation identifier everywhere, and redaction by allow-list.

Generate a request identifier at the edge, propagate it on every downstream call, and return it on every response, including errors. It is the first thing support will ask for.

Write a structured access log for every request. Redact at capture time and work by allow-list, so a new field is redacted by default. A deny-list only protects against the fields somebody remembered to add.

Set retention deliberately per category rather than applying one policy to everything. Access logs and authentication events usually need to live far longer than request bodies.

Test environments and documentation

Problem. A test mode implemented with special identifiers and conditional code paths is a second implementation of the API. The bugs live in the gap between the two, and they are found in production.

Solution. Run the production build on isolated infrastructure, with no test mode.

Same specification, same authentication, same errors, same limits. No magic identifiers and no special code paths. To test a suspended account, suspend an account.

What legitimately differs: no external side effects, outbound notifications captured rather than delivered, data purged after a period of inactivity, and the ability to reset a tenant through an API call. Keep those helpers under a dedicated path prefix that returns 404 in production.

Generate documentation from the OpenAPI specification in CI, so it cannot drift from the implementation. Add a getting-started guide, worked recipes for common flows, and a request collection. A managed developer portal is worth it at roughly 20 integrators, not before.

Using this as a checklist

Most of these decisions are cheap before launch and expensive afterwards. Versioning, identifier format, money representation, error shape and pagination model are all part of the contract from the first integration onwards. Tenant isolation and idempotency are the two where the cost of getting it wrong is not a refactor.

The order to settle them in is roughly the order above. Authentication and authorization decide what the rest can assume. Isolation decides whether the API can be multi-tenant at all. Everything after that is contract design, and contract design is easy to agree and hard to change.

Bring us your technology challenge.

Complex problems rarely fit inside one technology. Tell us what you are trying to solve and we will tell you how we would approach it.

Discuss a Technology Challenge