Software Journal
Tutorials Updated Jul 24, 2026 6 min read

Designing REST APIs That Stand the Test of Time

A practical tutorial on API design that survives contact with production: resource modeling, status codes, versioning, pagination, error shapes, and idempotency.

Benmalek Zohir

Contributor

Share
Illustration of REST API endpoints and HTTP requests

APIs are the longest-lived code most teams write. A frontend can be rewritten in a quarter; an API contract, once consumed by real clients, is hard to change without breaking people. This tutorial is about the decisions that make an API survive — the resource model, the status codes, the versioning, the pagination, and the error shape your future self will thank you for.

Resource modeling: nouns, not verbs

REST is about resources (nouns) acted on by HTTP methods (verbs). If your API reads like a procedure list — getUser, createUser, updateUserPassword, banUser — it’s an RPC API wearing REST clothing.

Model the resources, then let the methods do the work:

ResourceGETPOSTPATCHDELETE
/usersList usersCreate user
/users/{id}Get userUpdate userDelete user
/users/{id}/ordersList user’s ordersCreate order

The banUser problem is a great example of modeling thinking. “Ban a user” can be modeled as a sub-resource with its own lifecycle:

POST   /users/{id}/suspensions     → ban the user
DELETE /users/{id}/suspensions     → lift the ban
GET    /users/{id}/suspensions     → review the ban history

This gives you audit trail, effective dates, and reasons — for free — instead of a PATCH that sets active: false and loses all context.

Status codes are part of the contract

Correct status codes are not decoration; they’re how clients decide what to do. Learn the ones that matter:

  • 200 OK, 201 Created (return the created resource with a Location header)
  • 204 No Content (successful delete)
  • 400 Bad Request (malformed / invalid input)
  • 401 Unauthorized (no/invalid credentials), 403 Forbidden (not allowed even with credentials)
  • 404 Not Found, 409 Conflict (e.g., duplicate email), 422 Unprocessable Entity (semantically invalid)
  • 429 Too Many Requests (rate limited — always include a Retry-After header)
  • 5xx — never surface internal exception text here
// A good 422
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "The request could not be processed.",
    "details": [
      {
        "field": "email",
        "reason": "must be a valid email address",
        "value": "not-an-email"
      }
    ]
  }
}

Error shape: one shape, everywhere

Pick one error schema and use it for every error response. Clients should be able to write one error parser, not twenty.

{
  "error": {
    "code": "NOT_FOUND",
    "message": "No order found with id 'ord_1234'.",
    "requestId": "req_0a1b2c3d4e5f",
    "details": []
  }
}

The code is the machine-readable contract — stable, documented, and specific (INSUFFICIENT_FUNDS, not ERROR). The message is human-readable. The requestId makes support and debugging tractable. Never expose stack traces, SQL, or internal paths.

Versioning: put it in the URL, keep it honest

The Accept-header versioning crowd is technically elegant and practically painful — CDNs, debuggers, and curl get in the way. URL versioning is the boring choice that works:

https://api.example.com/v1/users/{id}
https://api.example.com/v2/users/{id}

Rules that keep versioning sane:

  • Version when you change a contract, not when you add a field. Additive changes (new fields, new optional params) don’t need a new version.
  • Never silently change a field’s meaning, type, or default.
  • Run v1 and v2 concurrently, and announce v1 deprecation with a real deadline.
  • Return a deprecation warning header so clients can discover they’re old: Deprecation: true, Sunset: Sat, 31 Dec 2027 23:59:59 GMT.

Pagination: cursor, not offset

Offset pagination (?page=2&limit=20) breaks when items are inserted or deleted between requests — you get duplicates and skips. Cursor pagination is stable:

GET /orders?limit=20&cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTI0IiwiaWQiOiJvcmRfMTAwIn0

The cursor is an opaque token encoding “where I was” (usually a key + id). Responses carry the next cursor:

{
  "data": [ /* 20 orders */ ],
  "pagination": {
    "nextCursor": "eyJjcmVhdGVk...",
    "hasMore": true
  }
}

The client just passes nextCursor back. It’s stable under concurrent writes, and it’s what every serious API eventually does — start with it.

Idempotency: make retries safe

Networks drop requests. Clients retry. If a retry double-creates an order, your API has a bug by design. Support idempotency keys on state-changing endpoints:

POST /payments
Idempotency-Key: 4a7c-11ec-90d6-0242ac120003

The server keys its cache on the idempotency key: a retry with the same key returns the original response. Implement it with a UNIQUE constraint on (idempotency_key, resource) and a created_at so the key eventually expires.

CREATE TABLE idempotency_keys (
  key        text PRIMARY KEY,
  user_id    uuid NOT NULL,
  response   jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

Consistency: naming, casing, and nulls

Small decisions, repeated thousands of times, become the API’s personality:

  • Naming: snake_case or camelCase — pick one and be consistent in the JSON body. id is a convention; don’t call it ID, Id, or identifier.
  • Plurals and nesting: POST /users, never /user and /Users. Keep nesting to 1-2 levels.
  • Timestamps: ISO 8601 UTC — 2026-07-24T09:30:00Z. Never epoch seconds, never local time.
  • Nulls: decide whether optional fields are omitted or null, and document it. Consistency here saves clients from real bugs.

A final checklist

Before you ship an endpoint:

  • Resource modeled as a noun, actions as sub-resources
  • Correct status code for every path
  • Error shape matches the one documented schema
  • Pagination is cursor-based where order matters
  • State-changing endpoints accept idempotency keys
  • Timestamps are ISO 8601 UTC
  • Location header on 201 responses
  • Rate-limit and deprecation headers in place

Conclusion

A great API is boring in the best way: consistent, predictable, and legible to the client that has to maintain three integrations. Model resources, not procedures. Return the same-shaped errors. Version when you break, never silently. And make retries safe from day one. Your API will outlive its first frontend, its first team, and probably its first rewrite — design it like it will.

Share
Portrait of Benmalek Zohir

Written by

Benmalek Zohir

Founder, AI Engineer & Full Stack Developer

Benmalek Zohir is an AI Engineer, Full Stack Developer, and technology enthusiast focused on artificial intelligence, software development, and emerging technologies. He is the founder of SoftwareJournal.blog, where he shares practical insights, software discoveries, AI tools, and the latest developments in technology.

The Software Journal Dispatch

One excellent engineering read, every week.

A concise digest of our best new essays on architecture, tooling, databases, and the craft of software. No spam, no noise — unsubscribe anytime.