API Design Best Practices

An API is a contract. Once published and consumed by clients, every breaking change carries a real cost — coordination overhead, client-side hotfixes, and eroded trust. The decisions you make when designing an API are disproportionately expensive to reverse, which is why getting them right from the start matters more than almost any other engineering choice.

This article covers the decisions I apply when designing production REST APIs: resource modeling, versioning, error handling, pagination, security, and documentation. These are not theoretical principles — they are patterns that have survived contact with real traffic, real clients, and real incidents.

Resource Modeling

REST is about resources, not actions. The URL identifies a resource; the HTTP method expresses what to do with it.

GET
Read — safe and idempotent
POST
Create — not idempotent
PUT
Full replace — idempotent
PATCH
Partial update — idempotent
DELETE
Remove — idempotent
# Good — nouns, plural, hierarchical GET /orders # list orders GET /orders/{id} # get one order POST /orders # create order PATCH /orders/{id} # update order DELETE /orders/{id} # cancel order GET /orders/{id}/items # list items in an order # Bad — verbs in URLs, RPC-style POST /getOrder POST /createNewOrder GET /deleteOrder?id=123

Actions That Don't Fit CRUD

Some operations are genuinely not CRUD: confirming an order, archiving a document, resending an email. Two approaches:

Prefer state transitions via PATCH when the action naturally maps to a state change. Use sub-resource actions for complex commands with multiple side effects (e.g., POST /invoices/{id}/send).

HTTP Status Codes

Use status codes semantically. Clients build logic on top of them — returning 200 for every response, including errors, forces clients to parse the body to know if the call succeeded.

# Success 200 OK — GET, PATCH success with body 201 Created — POST success; include Location header 204 No Content — DELETE, PATCH with no response body # Client errors 400 Bad Request — validation failure (include error details) 401 Unauthorized — missing or invalid authentication 403 Forbidden — authenticated but not authorized 404 Not Found — resource does not exist 409 Conflict — state conflict (e.g., duplicate, wrong status) 422 Unprocessable — semantically invalid (business rule violation) 429 Too Many Req. — rate limit exceeded # Server errors 500 Internal Error — unexpected failure (never expose stack traces) 503 Unavailable — downstream dependency down; include Retry-After

Consistent Error Response Structure

Every error response must follow the same structure. Clients must be able to parse errors programmatically, not by reading error messages written for humans.

// Standard error envelope { "type": "https://api.example.com/errors/validation-error", "title": "Validation Failed", "status": 400, "detail": "The request body contains invalid fields.", "traceId": "abc123xyz", "errors": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Must be a valid email address" }, { "field": "amount", "code": "OUT_OF_RANGE", "message": "Must be between 0.01 and 999999" } ] }

The traceId field is non-negotiable in production. When a client reports an error, you need to correlate it to a specific request in your distributed tracing system within seconds — not minutes.

API Versioning

APIs evolve. Without a versioning strategy, every change is a potential breaking change for every client. Three common approaches:

Recommendation: Use URL path versioning for public and partner APIs — it is explicit and cacheable. Use header versioning for internal service-to-service APIs where URL cleanliness matters and testing infrastructure is controlled.

Deprecation Policy

Never remove a version without notice. Include deprecation warnings in response headers starting at least 6 months before sunset:

Deprecation: Sun, 01 Jun 2025 00:00:00 GMT Sunset: Mon, 01 Dec 2025 00:00:00 GMT Link: <https://api.example.com/v2/orders>; rel="successor-version"

Pagination

Never return unbounded collections. A GET /orders endpoint that returns all orders will eventually destroy your database and timeout the client.

Cursor-based Pagination (preferred)

// Request GET /orders?cursor=eyJpZCI6MTIzfQ&limit=20 // Response { "data": [...], "pagination": { "limit": 20, "nextCursor": "eyJpZCI6MTQzfQ", // null if last page "hasMore": true } }

Cursor-based pagination is stable under concurrent writes — a new order inserted on page 1 doesn't cause items to skip or duplicate across pages. Offset-based (?page=3&size=20) is simpler but breaks under high write rates.

Security: Beyond Authentication

Rate Limiting

Implement rate limiting at the API Gateway layer. Return 429 with a Retry-After header and expose limit metadata so clients can back off gracefully:

HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1711929600 Retry-After: 47

Idempotency Keys for Non-Idempotent Operations

POST is not idempotent by default — a retry sends a duplicate request and creates a duplicate resource. For operations that can't be safely retried (charge a payment, send an email, create an order), use an idempotency key: a client-generated UUID sent as a header that ties the request to exactly one server-side effect.

# Client sends a unique key per logical operation POST /v1/payments Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 Content-Type: application/json { "customerId": "cust_123", "amountInCents": 5000, "currency": "BRL" } # Server stores (idempotency_key, response) in a table for ~24h # A second request with the same key returns the cached response # without executing the payment again

This is critical for payment flows. Network timeouts between the client and server leave the client uncertain: did the charge go through? Without idempotency keys, a retry risks double-charging. With them, the server detects the duplicate and returns the original result. Stripe, PayPal, and every serious payment API require this pattern.

Input Validation

Validate at the API boundary — never trust client input. Use Bean Validation in Spring Boot and validate all inputs before they touch business logic:

public record PlaceOrderRequest( @NotNull @Size(min=1, max=50) String customerId, @NotEmpty @Size(max=100) List<@Valid OrderItemRequest> items, @NotNull @DecimalMin("0.01") @DecimalMax("999999") BigDecimal totalAmount ) {}

Sensitive Data in Responses

Never expose internal identifiers, stack traces, database primary keys as sequential integers, or server implementation details in API responses. Sequential IDs allow enumeration attacks — use UUIDs or opaque tokens as public identifiers.

Contract-First with OpenAPI

Define your API contract in OpenAPI 3.x before writing any code. The spec becomes the single source of truth from which you generate server stubs, client SDKs, and interactive documentation.

openapi: 3.1.0 info: title: Order Service API version: 1.0.0 paths: /v1/orders: post: operationId: placeOrder requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PlaceOrderRequest' responses: '201': description: Order created headers: Location: schema: type: string '400': $ref: '#/components/responses/ValidationError' '422': $ref: '#/components/responses/BusinessRuleViolation'

Practice: Use openapi-generator to generate Spring controller interfaces from the spec. Your implementation must satisfy the contract — not the other way around. This prevents the spec from drifting out of sync with the real API.

Webhooks: Push-Based API Events

REST APIs are pull-based: the client asks, the server responds. Webhooks invert this — the server pushes events to a client-provided URL when something happens (payment authorized, order shipped, user signed up). This eliminates polling and delivers events in near-real-time without the client maintaining a persistent connection.

Event Payload Design

Use a consistent envelope for every webhook event. Include a unique event ID, the event type, a timestamp, and the versioned payload. The event ID is critical for idempotency on the receiver side:

// Standard webhook event envelope { "id": "evt_01HZBKJ8T2PRXM7FQD4AKVY9GN", // unique — used as idempotency key "type": "order.shipped", "apiVersion": "2025-03-01", "createdAt": "2025-04-15T14:22:00Z", "data": { "orderId": "ord_789xyz", "customerId": "cust_123", "trackingId": "BR123456789", "shippedAt": "2025-04-15T14:20:00Z" } }

Signature Verification (HMAC-SHA256)

Webhooks are unauthenticated HTTP requests from the receiver's perspective — anyone who discovers your endpoint URL can send fake events. Sign every delivery with HMAC-SHA256 using a shared secret, and verify before processing:

# Sender — sign the raw request body import hmac, hashlib def sign_webhook(payload: bytes, secret: str) -> str: return "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() # Header added to every delivery: Webhook-Signature: sha256=abc123... # Receiver — verify before trusting the payload def verify_webhook(payload: bytes, header_sig: str, secret: str) -> bool: expected = sign_webhook(payload, secret) return hmac.compare_digest(expected, header_sig) # constant-time — never use ==

Always use hmac.compare_digest (or its language equivalent) — never ==. Regular string equality short-circuits on the first differing byte, leaking timing information that attackers can exploit to forge valid signatures character by character.

Retries and Idempotency

Your delivery system will retry on timeout or non-2xx response. The receiver must be idempotent — use the event id as the key in a processed-events table so duplicate deliveries are silently discarded, and always respond fast:

# Retry schedule (exponential backoff) Attempt 1: immediate Attempt 2: 30 seconds Attempt 3: 5 minutes Attempt 4: 30 minutes Attempt 5: 2 hours → dead letter after final failure # Receiver — idempotency guard + async processing def handle_webhook(event: dict): if ProcessedEvent.exists(event["id"]): return 200 # already handled — return OK so retries stop queue.enqueue("process_event", event) # enqueue immediately ProcessedEvent.create(event["id"]) return 200 # respond within 5s or sender considers it a timeout

Respond fast, process async. Never do slow work — DB queries, API calls, email sends — synchronously in a webhook handler. Accept the event, enqueue it (SQS, RabbitMQ, BullMQ), and return 200 immediately. If your handler takes more than ~5 seconds, the sender retries the delivery and you process the event twice.

Key Takeaways

Back to all articles