Skip to content
Back to blog
RESTAPI DesignBackendHTTPBest Practices

REST API Design Best Practices: Versioning, Pagination, and Error Handling

July 4, 202613 min read

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.
Before
Anti-patterns — verbs in URLs, inconsistent naming
1# Verbs in URLs (wrong — HTTP verb is already the action)2POST /createOrder3GET  /getOrderById/1234POST /cancelOrder/1235GET  /getUserOrders?userId=4566 7# Inconsistent casing8GET /OrderItems/1239GET /order_items/12310 11# Over-nested12GET /users/456/orders/123/items/789/reviews13 14# Action-based instead of resource-based15POST /orders/123/doCancel
After
Clean resource-oriented URLs
1# Collections and resources2GET    /orders          # list orders3POST   /orders          # create order4GET    /orders/123      # get one order5PATCH  /orders/123      # partial update6PUT    /orders/123      # full replacement7DELETE /orders/123      # delete8 9# Relationships — one level of nesting10GET /orders/123/items   # items belonging to order 12311 12# Actions that don't map to CRUD — use sub-resource noun13POST /orders/123/cancellation    # cancel order (noun form)14POST /orders/123/shipments       # ship order15POST /users/456/password-reset   # trigger password reset16 17# Filtering, sorting, pagination — query params18GET /orders?status=pending&userId=456&sort=-createdAt&limit=20&cursor=xxx

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.
Before
Offset pagination — degrades at scale
1// Offset pagination (bad for large datasets)2GET /orders?page=5&pageSize=203 4// Server implementation — slow at high offsets5const orders = await db.orders6  .orderBy('createdAt', 'desc')7  .limit(20)8  .offset(80); // scans and discards 80 rows first9 10// Response — page numbers become meaningless as data changes11{12  "data": [...],13  "page": 5,14  "pageSize": 20,15  "total": 542016}
After
Cursor pagination — O(log n) regardless of position
1// Cursor pagination (scalable, consistent)2GET /orders?limit=20&cursor=eyJpZCI6MTIzLCJjcmVhdGVkQXQiOiIyMDI2LTA3In03 4// Server implementation — index seek, no scan5async function getOrders(cursor?: string, limit = 20) {6  const decoded = cursor7    ? JSON.parse(Buffer.from(cursor, 'base64url').toString())8    : null;9 10  const orders = await db.orders11    .where(decoded12      ? { createdAt: { lt: decoded.createdAt }, id: { lt: decoded.id } }13      : {})14    .orderBy([{ createdAt: 'desc' }, { id: 'desc' }])15    .limit(limit + 1); // fetch one extra to detect hasNextPage16 17  const hasNextPage = orders.length > limit;18  const items = orders.slice(0, limit);19  const nextCursor = hasNextPage20    ? Buffer.from(JSON.stringify({21        createdAt: items.at(-1)!.createdAt,22        id: items.at(-1)!.id23      })).toString('base64url')24    : null;25 26  return { data: items, nextCursor, hasNextPage };27}28 29// Response30{31  "data": [...20 orders],32  "nextCursor": "eyJpZCI6MTAzfQ",33  "hasNextPage": true34}

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.
Before
No versioning — breaking changes break clients
1// Original response — clients depend on this shape2GET /orders/1233{4  "id": "123",5  "customerName": "Alice",  // ← clients use this field6  "total": 99.997}8 9// Breaking change in production — crashes all existing clients10GET /orders/12311{12  "id": "123",13  "customer": {            // ← renamed and restructured14    "firstName": "Alice",15    "lastName": "Smith"16  },17  "amount": 99.99          // ← renamed18}
After
URL versioning — old clients unaffected
1// v1 preserved — old clients continue working2GET /v1/orders/1233{4  "id": "123",5  "customerName": "Alice",6  "total": 99.997}8 9// v2 introduces new shape — new clients opt in10GET /v2/orders/12311{12  "id": "123",13  "customer": {14    "firstName": "Alice",15    "lastName": "Smith"16  },17  "amount": 99.9918}19 20// Deprecation header on v1 responses21Deprecation: true22Sunset: Sat, 01 Jan 2027 00:00:00 GMT23Link: </v2/orders>; rel="successor-version"

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.
Before
Inconsistent errors — client can't handle generically
1// Login failure2{ "message": "Wrong credentials" }3 4// Validation failure — different shape5{ "error": true, "fields": { "email": "required" } }6 7// Rate limit — different shape again8{ "code": 429, "msg": "Too many requests" }9 10// Server error — reveals internals11{12  "error": "NullReferenceException at OrderService.cs:42"13}
After
RFC 7807 Problem Details — consistent across all endpoints
1// 401 — authentication error2{3  "type": "https://api.example.com/errors/unauthorized",4  "title": "Authentication required",5  "status": 401,6  "detail": "The provided token is expired.",7  "instance": "/orders/123"8}9 10// 422 — validation error with field-level detail11{12  "type": "https://api.example.com/errors/validation",13  "title": "Validation failed",14  "status": 422,15  "detail": "One or more fields failed validation.",16  "instance": "/orders",17  "errors": [18    { "field": "quantity", "message": "Must be greater than 0" },19    { "field": "productId", "message": "Product not found" }20  ]21}22 23// 429 — rate limit with retry guidance24{25  "type": "https://api.example.com/errors/rate-limited",26  "title": "Rate limit exceeded",27  "status": 429,28  "detail": "You have exceeded 100 requests per minute.",29  "retryAfter": 4530}31// Also set: Retry-After: 45 header

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.
Before
No idempotency — network retry creates duplicates
1// Client sends payment request2POST /payments3{ "orderId": "123", "amount": 99.99 }4 5// Network timeout — did the server process it?6// Client retries...7POST /payments8{ "orderId": "123", "amount": 99.99 }9 10// Server processed both — customer charged twice!
After
Idempotency key — safe to retry
1// Client generates a UUID per operation and sends it on every attempt2POST /payments3Idempotency-Key: f47ac10b-58cc-4372-a567-0e02b2c3d4794{ "orderId": "123", "amount": 99.99 }5 6// Server implementation7async function createPayment(req: Request) {8  const idempotencyKey = req.headers['idempotency-key'];9 10  if (idempotencyKey) {11    // Check if already processed12    const cached = await redis.get(`idem:${idempotencyKey}`);13    if (cached) {14      return Response.json(JSON.parse(cached), { status: 200 });15    }16  }17 18  // Process payment19  const payment = await processPayment(req.body);20  const response = { paymentId: payment.id, status: 'charged' };21 22  if (idempotencyKey) {23    // Store result for 24 hours24    await redis.set(`idem:${idempotencyKey}`, JSON.stringify(response), 'EX', 86400);25  }26 27  return Response.json(response, { status: 201 });28}29 30// Client retries with same key — gets original response, no duplicate charge

Remember this

Support Idempotency-Key on every state-changing POST. Clients will retry on network failure — without it, you'll create duplicates in production.

Key takeaway

Share:

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

Real-time communication between server and client breaks the standard HTTP request-response model. When you need live up

Read

Modern systems rarely pick one API style for everything. REST uses HTTP and JSON — simple, cache-friendly, and the defau

Read

Modern backends rarely speak one language. Clients hit REST endpoints through an API Gateway. Mobile apps send GraphQL q

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.