REST API Design in 2026: Richardson Maturity Model in Practice

Most APIs I’ve reviewed in the wild are pretending to be REST. They have JSON responses, they run over HTTP, someone slapped "RESTful" in the README — but underneath, they’re just RPC with a thin costume on. A single /api endpoint that accepts POST with a {"action": "getUser"} body. You know the type.

The Richardson Maturity Model, coined by Leonard Richardson and popularized by Martin Fowler, gives us a vocabulary and a ladder for this. Not as a certificate you earn, but as a diagnostic tool. Where does your API actually live on this ladder? What does it cost to climb higher? What do you gain? Those are the questions worth asking.

This article walks through all four levels with concrete HTTP examples, real configuration snippets, and honest opinions on where most teams should actually stop.


The Ladder at a Glance

Before going level by level, here’s the skeleton:

Level Name Key Idea
0 Swamp of POX HTTP as a tunnel
1 Resources Separate URIs per resource
2 HTTP Verbs Use verbs correctly, use status codes
3 Hypermedia (HATEOAS) Responses describe available actions

Most production APIs sit at Level 2. A few reach Level 3. A depressing number are still at Level 0.


Level 0: The Swamp of POX

POX = Plain Old XML (or JSON, in modern times). Level 0 treats HTTP purely as a transport mechanism. One endpoint, one method (always POST), and everything is encoded in the body.

POST /api HTTP/1.1
Content-Type: application/json

{
  "action": "createOrder",
  "userId": 42,
  "items": [101, 202]
}

This is SOAP’s spiritual successor. It’s also what you get when a backend team that thinks in terms of function calls designs an HTTP API. The HTTP layer is invisible to them — they just want to send and receive structs.

Why it’s a problem: Caching becomes impossible because everything is a POST. HTTP intermediaries (proxies, CDNs, gateways) can’t do anything useful with the requests. Error handling is ad-hoc — you might get a 200 OK response with {"error": "user not found"} in the body, and your client has to know to check that. Load balancers can’t route intelligently. Every client needs out-of-band knowledge of what actions exist.

Level 0 isn’t automatically wrong for internal microservice communication where you control both ends and performance is paramount. But if you’re building a public-facing API and calling it REST, this is where the self-deception starts.


Level 1: Resources

The first real step. You stop routing by action and start routing by resource. Each noun gets its own URI.

# Before (Level 0)
POST /api {"action": "getUser", "id": 42}
POST /api {"action": "createOrder", "userId": 42}

# After (Level 1)
POST /users/42
POST /orders

You’ve separated concerns. Users live at /users, orders at /orders. The structure of your URI space now tells clients something about what data you’re managing.

But Level 1 still has a critical flaw: it typically uses POST for everything. Want to fetch a user? POST /users/42. Want to delete an order? POST /orders/99 with a body like {"delete": true}. The HTTP semantics are still ignored.

Gotcha: A common mistake at this level is embedding actions in the URI itself — /users/42/activate, /orders/99/cancel. This is tempting and not entirely wrong (more on this later), but it becomes a smell if you’re using it to avoid thinking about proper HTTP verb usage. /users/42/activate via POST is actually a reasonable pattern for state transitions that don’t map cleanly to CRUD. The problem is when people use it as a crutch for everything.


Level 2: HTTP Verbs — Where Most Teams Should Actually Live

This is the real REST. You combine proper resource-oriented URIs with meaningful HTTP methods, and you use status codes to communicate outcomes.

# Fetch a user
GET /users/42

# Create an order
POST /orders
Content-Type: application/json

{
  "userId": 42,
  "items": [{"productId": 101, "qty": 2}]
}

# Update a specific order (full replacement)
PUT /orders/99
Content-Type: application/json

{
  "status": "shipped",
  "items": [{"productId": 101, "qty": 2}]
}

# Partial update
PATCH /orders/99
Content-Type: application/json

{"status": "shipped"}

# Delete
DELETE /orders/99

And critically — status codes that mean something:

# Success responses
200 OK          → GET, PUT, PATCH succeeded, body returned
201 Created     → POST created a resource; include Location header
204 No Content  → DELETE or PATCH succeeded, no body needed

# Client errors
400 Bad Request  → Malformed input, validation failure
401 Unauthorized → Not authenticated (despite the name)
403 Forbidden    → Authenticated but not authorized
404 Not Found    → Resource doesn't exist
409 Conflict     → State conflict (e.g., duplicate, optimistic lock fail)
422 Unprocessable Entity → Syntactically valid but semantically wrong

# Server errors
500 Internal Server Error → Something blew up on your end
503 Service Unavailable   → Overloaded or down for maintenance

Production-ready: Structured error responses. A 400 with an empty body is useless. Return a consistent error object:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request body contains invalid fields",
    "details": [
      {"field": "items[0].qty", "issue": "must be greater than 0"}
    ]
  }
}

Pick a schema and stick to it. RFC 9457 (Problem Details for HTTP APIs) is the modern standard for this and worth adopting:

{
  "type": "https://api.yourservice.com/errors/validation-failed",
  "title": "Validation Failed",
  "status": 400,
  "detail": "The field 'qty' must be greater than 0",
  "instance": "/orders"
}

Gotcha: Idempotency. GET, PUT, DELETE should be idempotent — calling them multiple times should produce the same result. POST is not idempotent by definition. This has real consequences: if a client’s network drops after sending a POST but before getting a response, it doesn’t know if the order was created. The solution is an idempotency key:

POST /orders
Idempotency-Key: a3f7b82c-1234-4a99-b000-fc123456789a
Content-Type: application/json

{"userId": 42, "items": [...]}

Your server stores this key with the result. If the same key comes in twice, return the cached result instead of processing again. Stripe does this. You should too, for any resource-creating endpoint.

Gotcha: PUT vs PATCH confusion. PUT replaces the entire resource. If you PUT a user object and forget to include their email field, you’ve just nulled it out. PATCH is for partial updates. Many teams use PATCH exclusively and that’s fine — just be consistent. If you use both, document the difference clearly.

Gotcha: Versioning. Don’t ignore this at Level 2. When your API breaks compatibility, clients break. Three common strategies:

# URI versioning (most common, most visible)
GET /v1/users/42
GET /v2/users/42

# Header versioning (cleaner URIs, harder to test in browser)
GET /users/42
Accept: application/vnd.yourapi.v2+json

# Query parameter (avoid — caching gets messy)
GET /users/42?version=2

URI versioning wins on practicality. Yes, it "pollutes" your URI space. I don’t care. Every developer can see it, log it, and route on it without special tooling.


Level 2.5: The OpenAPI Contract

Not officially a level, but I’m inserting it here because in 2026, skipping this is malpractice.

Document your Level 2 API with an OpenAPI 3.1 spec. It serves as the source of truth for clients, generates SDKs, powers your API gateway, and makes onboarding new developers 10x faster.

A minimal example for the orders endpoint:

openapi: 3.1.0
info:
  title: Orders API
  version: 1.0.0
paths:
  /orders:
    post:
      summary: Create an order
      operationId: createOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
      responses:
        '201':
          description: Order created
          headers:
            Location:
              description: URI of the created order
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /orders/{orderId}:
    get:
      summary: Fetch an order by ID
      operationId: getOrder
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  schemas:
    Order:
      type: object
      properties:
        id:
          type: integer
        status:
          type: string
          enum: [pending, confirmed, shipped, delivered, cancelled]
        userId:
          type: integer
        items:
          type: array
          items:
            $ref: '#/components/schemas/OrderItem'
    OrderItem:
      type: object
      properties:
        productId:
          type: integer
        qty:
          type: integer
          minimum: 1
    CreateOrderRequest:
      type: object
      required: [userId, items]
      properties:
        userId:
          type: integer
        items:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/OrderItem'
  responses:
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'
    ProblemDetails:
      type: object
      properties:
        type:
          type: string
        title:
          type: string
        status:
          type: integer
        detail:
          type: string

If you’re writing this by hand for a large API, stop. Use a code-first approach — annotate your controllers and generate the spec from code. For Python (FastAPI), Node (tsoa, NestJS), Go (swaggo) — the ecosystem is mature. The spec should be in source control and validated in CI.


Level 3: Hypermedia (HATEOAS)

HATEOAS stands for Hypermedia As The Engine Of Application State. The idea: responses include links to related actions and resources, so clients don’t need to hardcode URI patterns. The server drives navigation.

GET /orders/99
{
  "id": 99,
  "status": "confirmed",
  "userId": 42,
  "total": 89.99,
  "_links": {
    "self": {"href": "/orders/99", "method": "GET"},
    "cancel": {"href": "/orders/99/cancel", "method": "POST"},
    "invoice": {"href": "/orders/99/invoice", "method": "GET"},
    "user": {"href": "/users/42", "method": "GET"}
  }
}

If the order were already shipped, the cancel link wouldn’t appear. The client doesn’t need to know business rules about when cancellation is allowed — the server communicates that through the presence or absence of the link.

The formal media type for this is HAL (Hypertext Application Language), and there’s also JSON:API and Siren if you want something more opinionated.

Honest take: Level 3 is theoretically elegant and practically rare. The original REST thesis by Roy Fielding does require HATEOAS for a "true" REST API — but Fielding’s architecture was designed for the web as a distributed hypermedia system, not for the typical CRUD backend serving a mobile app.

The real benefit of HATEOAS shows up in two scenarios:

  1. Complex state machines. If your resources have many states and transitions (like an order with 8+ statuses), HATEOAS keeps clients decoupled from your state machine logic. When you add a new status, clients don’t need updating — they just follow links.

  2. Generic API explorers and tooling. An API browser can follow links without knowing your domain. This is genuinely useful for internal platforms and API gateways.

Gotcha: HATEOAS doesn’t mean your clients will actually use the links. Every client I’ve worked with in production either ignores the _links field entirely or hardcodes the URIs anyway. If you implement Level 3 for a team that controls all clients and breaks that discipline, you’ve added serialization overhead for nothing.

Gotcha: The overhead of generating _links on every response can become measurable at high throughput, especially if link availability depends on database state. Profile before committing to this in a hot path.


Where Should You Actually Target?

Level 2 + OpenAPI spec + RFC 9457 error format + idempotency keys. That’s the pragmatic production target for 99% of APIs. It’s understandable to every developer, toolable by every API gateway, cacheable by HTTP infrastructure, and version-controlled with swagger-ui or Redoc.

Level 3 is worth the investment for:

  • Public platform APIs with complex domain objects (GitHub does partial HATEOAS, Stripe does too)
  • Hypermedia-heavy workflows (multi-step onboarding, document approval chains)
  • APIs consumed by generic clients you don’t control

Skip Level 3 if you control all clients, your domain is CRUD-heavy, or your team isn’t bought in — half-hearted HATEOAS is worse than none.


Common Anti-patterns at Level 2 Worth Calling Out

Verb tunneling. POST /users/42?action=deactivate — you’ve gone backwards. Use POST /users/42/deactivations or PATCH /users/42 with {"active": false}. Pick one, document it.

Returning 200 for everything. This breaks HTTP clients, caches, and monitoring. A 200 with {"success": false, "error": "not found"} in the body is a special kind of cruelty. Return 404.

Overfetching and underfetching on the same endpoint. If clients constantly need to request 5 different endpoints to render one screen, you need either field selection (?fields=id,name,status) or a GraphQL layer on top. REST doesn’t solve the N+1 problem — but it shouldn’t make it worse by design.

Inconsistent naming conventions. Pick camelCase or snake_case for JSON keys and die on that hill. /user-orders or /userOrders for URI segments — again, pick one. Mixed conventions in a single API are a sign of no code review culture.

Missing pagination on list endpoints. GET /orders returning 50,000 records on the first call is how you take down your own API. Cursor-based pagination is better than offset for large datasets:

GET /orders?limit=50&cursor=eyJpZCI6OTl9

# Response
{
  "data": [...],
  "pagination": {
    "next_cursor": "eyJpZCI6MTQ5fQ==",
    "has_more": true
  }
}

Offset pagination (?page=2&per_page=50) is fine for small datasets and admin UIs where users skip to page 37. Cursor pagination wins for infinite scroll and high-throughput APIs.


A Note on GraphQL and gRPC in 2026

Some teams reach for GraphQL or gRPC when they’re frustrated with REST’s limitations — usually overfetching or type safety. Both are legitimate tools.

GraphQL wins when: clients have very different data needs, you’re aggregating multiple backends, and you can invest in a schema-first workflow with proper tooling.

gRPC wins when: you’re doing internal service-to-service communication, performance is critical, and polyglot strongly-typed contracts matter more than browser accessibility.

REST wins when: you’re building a public API, you want HTTP semantics to work for you (caching, routing, observability), and your team already understands HTTP. That’s most cases.

Don’t add GraphQL because your React developers asked nicely. Add it because you have a genuine N+1 problem or client diversity problem that REST can’t solve without significant query parameter gymnastics.


The Actual Takeaway

Richardson’s model is a diagnostic, not a target. Use it to understand where your API is weak and whether climbing the ladder buys you something real.

Level 0: refactor away from this, it will haunt you.
Level 1: better, but incomplete.
Level 2: ship it — with proper verbs, status codes, versioning, and an OpenAPI spec.
Level 3: add it when the state machine complexity genuinely warrants it, not because someone read a blog post about Fielding.

Write the simplest API that serves your clients well, document it properly, and version it before you need to. That’s better REST design than most teams manage.

👁 Views: 112,866 · Unique visitors: 45,461