REST API Design Best Practices: Versioning, Pagination, and Error Handling
A well-designed REST API is a contract. Clients depend on your URL structure, your error format, and your pagination scheme for months or years after you publish it. Breaking changes silently corrupt client data and cause outages. A poorly designed API that becomes popular is more painful than one that never gets used — you're stuck supporting your mistakes forever.
This article covers the conventions that make REST APIs consistent, predictable, and pleasant to work with: resource naming, HTTP verb semantics, status codes, cursor vs offset pagination, versioning strategies, idempotency, and a standard error response format that gives clients actionable information.
Resource Naming and URL Design
REST URLs represent resources — things, not actions. The HTTP verb is the action. This distinction shapes every URL decision: /orders (the resource), POST /orders (create one), GET /orders/123 (fetch one), PATCH /orders/123 (update one), DELETE /orders/123 (delete one).
Use plural nouns for collections, kebab-case for multi-word resources, and nest resources only one level deep for relationships. Deeper nesting creates brittle, long URLs that break when resource ownership changes. For relationships, prefer query parameters or a flat resource with filtering over deep nesting.
Quick reference
- Plural nouns for collections: /orders, /users, /products — not /order, /user, /product.
- kebab-case for multi-word resources: /order-items, /payment-methods — not camelCase in URLs.
- Max one level of nesting for relations: /orders/123/items is fine, /orders/123/items/789/reviews is too deep.
- For actions, use sub-resource nouns: /orders/123/cancellation not /orders/123/cancel.
- Don't encode the API version in every resource URL if you can avoid it — use a header or top-level prefix.
- Treat query parameters as the interface for filtering, sorting, and pagination — not as part of the resource identity.
Remember this
URLs are nouns, HTTP verbs are actions. Keep nesting shallow. Use sub-resource nouns for actions that don't map to CRUD.
HTTP Verbs and Status Codes
HTTP verbs carry precise semantics. GET is safe (no side effects) and idempotent (repeated calls return same result). PUT and DELETE are idempotent (same result if called once or a hundred times). POST is neither safe nor idempotent. PATCH is typically not idempotent.
Status codes communicate the outcome class before the client parses the body. 2xx means success (201 Created when you create something, 204 No Content when you delete something, 200 OK for everything else). 4xx is client error — something about the request is wrong and retrying the same request won't help. 5xx is server error — something went wrong on your side and retrying might work.
Quick reference
- 200 OK: successful GET, PATCH, PUT. Return the updated resource.
- 201 Created: successful POST. Return the created resource + Location header with its URL.
- 204 No Content: successful DELETE or PUT with no response body. Do not return 200 with an empty body.
- 400 Bad Request: malformed request syntax, invalid field values, missing required fields.
- 401 Unauthorized: missing or invalid credentials. Despite the name, this means 'unauthenticated'.
- 403 Forbidden: authenticated but not authorized for this resource or action.
- 404 Not Found: resource doesn't exist. Also use when you don't want to reveal resource existence (security).
- 409 Conflict: state conflict (duplicate email, optimistic locking failure, stale version).
- 422 Unprocessable Entity: syntactically valid but semantically invalid (order total doesn't match items).
- 429 Too Many Requests: rate limit exceeded. Include Retry-After header.
- 500 Internal Server Error: generic server failure. Never expose stack traces in production.
Remember this
Return 201 + Location on create, 204 on delete, 404 for missing resources, 400/422 for bad input, 401/403 for auth issues. Never return 200 for errors.
Pagination: Cursor vs Offset
Offset pagination (LIMIT 20 OFFSET 100) is easy to implement and works well for small datasets. As the dataset grows, it has two critical problems: performance (the database must scan and discard 100 rows before returning 20) and consistency (if a row is inserted at page 1 while you're fetching page 2, rows shift and you see duplicates or skip entries).
Cursor-based pagination solves both problems. Instead of an offset, the client sends an opaque cursor encoding the last-seen item's position (typically a timestamp or ID). The server uses it as a WHERE condition, which the database can satisfy with an index. Cursors are stable under inserts — the cursor encodes a position, not an offset.
Quick reference
- Offset pagination: good for <10k rows or when users need to jump to page N.
- Cursor pagination: required for feeds, timelines, and tables >100k rows.
- Encode cursors as opaque Base64 strings — clients should not parse or construct them.
- Stable sort: always include a unique column (id) as a tiebreaker to prevent cursor ambiguity.
- Include hasNextPage in response — clients should not infer by checking if data.length === limit.
- Never expose internal IDs as cursors — use encoded composites (timestamp + id) for security and stability.
Remember this
Use offset pagination for admin UIs with small datasets. Use cursor pagination for any feed or large table. Always include a tiebreaker in the sort.
API Versioning Strategies
You will need to make breaking changes. The question is not whether to version but how. Three strategies dominate: URL versioning (/v1/orders), Accept header versioning (Accept: application/vnd.myapp.v2+json), and query parameter versioning (?version=2).
URL versioning is the most common in practice because it's visible, easy to route, and works with every HTTP client and proxy without configuration. The trade-off is that clients must change their URL to upgrade. Accept header versioning is cleaner but harder to test in a browser and requires correct content-negotiation handling. The most important rule regardless of strategy: never remove a version without sufficient deprecation notice (at minimum 6 months).
Quick reference
- URL versioning (/v1, /v2): most common, explicit, easy to route, works everywhere.
- Header versioning (Accept: application/vnd.myapp.v2+json): cleaner URLs, harder to test and debug.
- Never remove a version without: deprecation header on responses, public sunset date, minimum 6 months notice.
- Additive changes (new fields, new endpoints) are non-breaking — no version bump needed.
- Breaking changes: field removed, field renamed, field type changed, behavior changed, endpoint removed.
- Maintain at most 2 active major versions simultaneously — maintaining more is unsustainable.
Remember this
Use URL versioning (/v1). Never remove a version without 6+ months deprecation notice. Additive changes are free — only removals and renames require version bumps.
Standard Error Response Format
Inconsistent error responses are the most painful API characteristic. When status=400 sometimes means validation errors and sometimes means malformed JSON, clients need conditional parsing logic for every endpoint. A consistent error format lets clients handle errors generically.
The RFC 7807 Problem Details standard (also used in .NET's ProblemDetails) gives you a standardized JSON envelope: type (a URI identifying the error type), title (human-readable summary), status (HTTP status code), detail (explanation for this specific occurrence), and instance (a URI for this specific occurrence). Extend it with errors (array of field-level validation errors) for validation responses.
Quick reference
- RFC 7807 (Problem Details for HTTP APIs) is the standard — use it or a superset of it.
- .NET 8 built-in: return TypedResults.ValidationProblem() / TypedResults.Problem() — they emit RFC 7807.
- Never include stack traces, SQL errors, or internal paths in production error responses.
- The errors array for validation failures enables clients to highlight specific form fields.
- The type URI should be a stable, documented URL — even if it returns a 404, clients use it as an identifier.
- Log correlation ID (trace ID) and include it in the error response instance — makes debugging tractable.
Remember this
Adopt RFC 7807 Problem Details. Consistent error format means client error handling code is written once. Never expose internals in error bodies.
Idempotency and Retry Safety
Network failures are inevitable. When a client sends a request and gets no response, it doesn't know if the server processed it or not. For GET requests, retrying is always safe — GET is idempotent by definition. For POST requests (create operations), retrying without idempotency protection creates duplicate records: two payments charged, two orders created, two emails sent.
The solution is an Idempotency-Key header. The client generates a unique key (UUID) per logical operation and sends it on every attempt. The server checks if it's already processed that key. If yes, it returns the original response without re-executing. If no, it processes and stores the response under that key.
Quick reference
- Generate idempotency keys client-side (UUID v4) — one per logical operation, not per HTTP request.
- Store responses server-side for 24–72 hours — TTL based on expected retry window.
- Return the original response on duplicate: same status code, same body. Don't return 200 if original was 201.
- Scope keys per user if you support multi-user contexts: hash(userId + idempotencyKey).
- PUT and DELETE are inherently idempotent by HTTP spec — only POST needs explicit idempotency handling.
- Stripe, Adyen, Twilio all use Idempotency-Key. It's the industry standard for payment APIs.
Remember this
Support Idempotency-Key on every state-changing POST. Clients will retry on network failure — without it, you'll create duplicates in production.
A REST API is a long-term contract. The decisions you make on day one — URL structure, error format, pagination strategy, versioning scheme — will constrain you for years. The conventions in this article exist because the industry learned these lessons the hard way, usually by breaking millions of client integrations.
The summary: nouns not verbs in URLs, correct HTTP status codes everywhere, cursor pagination for large datasets, URL versioning with explicit deprecation notices, RFC 7807 for errors, and Idempotency-Key on all POST endpoints that charge money or send messages. These aren't opinions — they're the difference between an API that developers trust and one they work around.
Related Articles
Explore this topic