In the previous lesson we saw that REST is met in degrees, and that almost no real API is fully RESTful. That leaves a very practical open question: how is that degree measured? In 2008 Leonard Richardson proposed a four-level model that has become the industry's standard vocabulary. In this lesson we will work through the four levels by rewriting the same Aroma Store operation — creating an order — at each of them, so that the progression can be seen with your own eyes. We will then get into HATEOAS, the highest and most disputed level, look at real hypermedia formats such as HAL and JSON:API, and close with an honest debate about when it pays to go all the way to the top and when it does not.

Contents

  1. What the maturity model is for
  2. Level 0: the swamp of POX
  3. Level 1: resources
  4. Level 2: HTTP verbs and status codes
  5. Level 3: hypermedia controls (HATEOAS)
  6. The four levels at a glance
  7. What HATEOAS is and what problem it solves
  8. Hypermedia formats: HAL, JSON:API and Siren
  9. The honest debate: why does almost nobody reach level 3?
  10. Criteria for deciding your level

  1. What the maturity model is for

Leonard Richardson presented this model at the QCon conference in 2008, and Martin Fowler popularised it in a 2010 article. Its virtue is twofold:

  • It offers a shared vocabulary: saying "we are at level 2 and we do not plan to go higher" communicates an entire architectural decision in a few words.
  • It turns a binary, sterile argument ("is this REST or not?") into a progressive scale you can reason about.

Two warnings before we start:

  1. It is not Fielding's and it is not normative. It is a descriptive model, useful for diagnosis, not an exam you have to pass.
  2. Moving up a level is not automatically better. Each level has a cost. The goal is to choose with judgement, not to maximise your score.

  1. Level 0: the swamp of POX

POX stands for Plain Old XML, although today the swamp is more likely to be made of JSON. Its hallmarks:

  • A single endpoint for everything.
  • A single method, almost always POST.
  • The operation to be executed goes inside the body.
  • HTTP is used as a mere transport tunnel: its methods, its codes and its caching are ignored entirely.

This is how Aroma Store would create an order at level 0:

POST /api HTTP/1.1
Host: api.aromastore.example
Content-Type: application/json

{
  "action": "createOrder",
  "params": {
    "customerId": "cus_842",
    "items": [{ "coffeeId": "cof_001", "quantity": 2 }]
  }
}

And the response:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "result": { "orderId": "ord_5001", "totalEuros": 29.00 }
}

And this is how it would look up that same order:

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

{ "action": "getOrder", "params": { "orderId": "ord_5001" } }

What is wrong with it, in concrete, measurable terms:

Problem Practical consequence
Everything goes by POST No response can be cached, neither by the browser nor by a CDN
There are no resource URIs You cannot share or bookmark a link to an order
The error travels in the body with 200 OK Monitoring does not detect failures; automatic retries do not work
The semantics live in action No intermediary understands anything without knowing your domain
There is no idempotency A retry after a network failure creates a second order

This is, essentially, RPC over HTTP. It is exactly what SOAP did (lesson 01-06) and what today... GraphQL does, which also uses a single endpoint and POST (lesson 01-07). With one important difference: GraphQL does it deliberately, with a typed contract and its own tooling that compensates for what it gives up. Level 0 is usually accidental.

  1. Level 1: resources

The first leap: stop having a single endpoint and give every thing in the domain its own identity. You no longer talk to "the API", you talk to specific resources.

POST /v1/orders HTTP/1.1
Content-Type: application/json

{
  "action": "create",
  "customerId": "cus_842",
  "items": [{ "coffeeId": "cof_001", "quantity": 2 }]
}
HTTP/1.1 200 OK
Content-Type: application/json

{ "success": true, "orderId": "ord_5001", "totalEuros": 29.00 }

Looking that order up, at level 1:

POST /v1/orders/ord_5001 HTTP/1.1
Content-Type: application/json

{ "action": "get" }

What has been gained:

  • Each order has its own URI: /v1/orders/ord_5001. It can be linked, recorded in logs and routed distinctly.
  • The load can be split by resource: orders to some servers, the catalogue to others.
  • The API is far more comprehensible when you read a log or a trace.

What is still missing:

  • POST is still used for everything, even for reading. No caching, and no distinction between reads and writes.
  • The verb is still in the body (action).
  • Status codes are still unused.

Level 1 is a transitional state: many people reach it while reorganising an old API and stop halfway. Recognising it has diagnostic value: if your URLs are good but everything is a POST, you are here.

  1. Level 2: HTTP verbs and status codes

Here is the big leap, and where the vast majority of professional APIs live, including the one we will build in module 3. It consists of using the protocol as it was designed: the verb indicates the intent and the status code indicates the result.

Creating an order:

POST /v1/orders HTTP/1.1
Host: api.aromastore.example
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

{
  "customerId": "cus_842",
  "items": [{ "coffeeId": "cof_001", "quantity": 2 }]
}
HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/orders/ord_5001
Cache-Control: no-store

{
  "id": "ord_5001",
  "customerId": "cus_842",
  "status": "pending_payment",
  "totalEuros": 29.00,
  "items": [
    { "coffeeId": "cof_001", "name": "Ethiopia Yirgacheffe", "quantity": 2, "priceEuros": 14.50 }
  ],
  "createdAt": "2026-08-14T09:12:44Z"
}

Note three details that did not exist at the previous levels:

  • 201 Created instead of 200 OK: it states precisely that something new has been created.
  • Location: it says where the resource ended up, without the client having to compose the URL.
  • Cache-Control: no-store: an explicit instruction that nobody should store an order's data.

The remaining operations on the same resource, now without any action field:

# Look up an order
curl -H "Authorization: Bearer TOKEN" \
  https://api.aromastore.example/v1/orders/ord_5001               # -> 200 OK

# List a customer's orders
curl -H "Authorization: Bearer TOKEN" \
  "https://api.aromastore.example/v1/orders?customerId=cus_842"   # -> 200 OK

# Cancel an order
curl -X DELETE -H "Authorization: Bearer TOKEN" \
  https://api.aromastore.example/v1/orders/ord_5001               # -> 204 No Content

And errors are expressed in the protocol itself:

HTTP/1.1 422 Unprocessable Content
Content-Type: application/json

{
  "error": {
    "code": "insufficient_stock",
    "message": "There is not enough stock to complete the order",
    "details": [
      { "coffeeId": "cof_001", "requested": 2, "available": 0 }
    ]
  }
}

The code 422 says "I understood your request but I cannot process it because of its content"; the body gives the detail in a form the developer can read. Machines and humans, each with their own information.

Benefits accumulated at level 2:

  • Real caching on GETs, with a direct impact on cost and latency.
  • Idempotency on GET, PUT and DELETE: retries are safe.
  • Automatic monitoring: any tool counts the 5xx without knowing anything about coffee.
  • A minimal learning curve for consumers: if they know HTTP, they already know your API.

  1. Level 3: hypermedia controls (HATEOAS)

The last level adds links to the responses: the server returns not only data, but also which transitions are possible from the current state.

HTTP/1.1 201 Created
Content-Type: application/json
Location: /v1/orders/ord_5001

{
  "id": "ord_5001",
  "status": "pending_payment",
  "totalEuros": 29.00,
  "items": [
    { "coffeeId": "cof_001", "name": "Ethiopia Yirgacheffe", "quantity": 2, "priceEuros": 14.50 }
  ],
  "_links": {
    "self":     { "href": "/v1/orders/ord_5001" },
    "pay":      { "href": "/v1/orders/ord_5001/payment", "method": "POST" },
    "cancel":   { "href": "/v1/orders/ord_5001", "method": "DELETE" },
    "customer": { "href": "/v1/customers/cus_842" },
    "items":    { "href": "/v1/orders/ord_5001/items" }
  }
}

The interesting part happens when the state changes. Once paid and shipped, the same GET /v1/orders/ord_5001 request returns different links:

{
  "id": "ord_5001",
  "status": "shipped",
  "totalEuros": 29.00,
  "_links": {
    "self":     { "href": "/v1/orders/ord_5001" },
    "tracking": { "href": "/v1/orders/ord_5001/shipment" },
    "invoice":  { "href": "/v1/orders/ord_5001/invoice" },
    "return":   { "href": "/v1/orders/ord_5001/return", "method": "POST" }
  }
}

pay and cancel have gone — they are no longer possible — and tracking, invoice and return have appeared. The client does not need to know the order's state machine: it simply has to render the buttons corresponding to the links it receives. If tomorrow Aroma Store decides that shipped orders can also be forwarded as a gift, a new link appears and clients able to interpret it will show it without deploying a new version.

graph TD
    N0["<b>Level 0</b><br/>The swamp of POX<br/><i>one endpoint, all POST</i>"] --> N1
    N1["<b>Level 1</b><br/>Resources<br/><i>each thing with its own URI</i>"] --> N2
    N2["<b>Level 2</b><br/>HTTP verbs and codes<br/><i>GET/POST/PUT/DELETE + 2xx/4xx/5xx</i>"] --> N3
    N3["<b>Level 3</b><br/>Hypermedia controls<br/><i>HATEOAS: links that guide</i>"]
    N2 -.- M["This is where most<br/>professional APIs sit"]

  1. The four levels at a glance

Level 0 Level 1 Level 2 Level 3
URIs Just one One per resource One per resource One per resource
Methods POST only POST only All, with their semantics All, with their semantics
Status codes Always 200 Always 200 The correct ones The correct ones
Caching Impossible Impossible Yes, on reads Yes, on reads
Discovery Documentation Documentation Documentation Documentation + links
Client coupling Very high High Medium Low
Implementation cost Low Low Medium High
Typical example SOAP, legacy APIs Half-finished reorganisations Most current APIs Payment APIs, some mature public ones

  1. What HATEOAS is and what problem it solves

HATEOAS stands for Hypermedia As The Engine Of Application State. Translated: the client moves through the application by following the links the server gives it, just as you browse a website without knowing its URLs by heart.

The browser analogy is the most illuminating one. When you enter an online shop:

  • You do not type out shop.example/cart/add?product=123 by hand.
  • You click "Add to basket", a link or a form that the page itself gave you.
  • If the product is out of stock, that button simply is not there.

The browser knows nothing about coffee or baskets: it knows how to follow links and submit forms. HATEOAS aims to bring that same capability to programmatic clients.

The problem it solves is coupling to URLs and to business rules:

Without HATEOAS With HATEOAS
The client builds URLs by concatenating strings The client follows the links it receives
Changing a URL breaks every client URLs can change without breaking anything
The client replicates the state machine ("if status == 'pending_payment', show pay") The server decides and communicates it with links
Adding an operation requires deploying the client The operation appears as a new link

  1. Hypermedia formats: HAL, JSON:API and Siren

If every API invents its own way of expressing links, half the benefit is lost. That is why standardised formats exist. Let's look at the same Aroma Store order in the two most widely used ones.

HAL (Hypertext Application Language)

It is the lightest and the most widely adopted. It adds two conventions: _links for links and _embedded for embedded resources. Its content type is application/hal+json.

GET /v1/orders/ord_5001 HTTP/1.1
Accept: application/hal+json

HTTP/1.1 200 OK
Content-Type: application/hal+json
{
  "id": "ord_5001",
  "status": "pending_payment",
  "totalEuros": 29.00,
  "createdAt": "2026-08-14T09:12:44Z",
  "_links": {
    "self":     { "href": "/v1/orders/ord_5001" },
    "customer": { "href": "/v1/customers/cus_842" },
    "pay":      { "href": "/v1/orders/ord_5001/payment" },
    "cancel":   { "href": "/v1/orders/ord_5001" }
  },
  "_embedded": {
    "items": [
      {
        "coffeeId": "cof_001",
        "name": "Ethiopia Yirgacheffe",
        "quantity": 2,
        "priceEuros": 14.50,
        "_links": { "coffee": { "href": "/v1/coffees/cof_001" } }
      }
    ]
  }
}

HAL's advantage: it is a very thin layer over the JSON you already had. Its limitation: the links do not say which method to use or which fields to send; that has to be documented separately or extended by convention.

JSON:API

This is a far stricter and more complete format, with its own specification and the content type application/vnd.api+json. It standardises not only links, but the data structure, relationships, the inclusion of related resources, pagination, filtering and errors.

{
  "data": {
    "type": "orders",
    "id": "ord_5001",
    "attributes": {
      "status": "pending_payment",
      "totalEuros": 29.00,
      "createdAt": "2026-08-14T09:12:44Z"
    },
    "relationships": {
      "customer": {
        "data": { "type": "customers", "id": "cus_842" },
        "links": { "related": "/v1/customers/cus_842" }
      },
      "items": {
        "links": { "related": "/v1/orders/ord_5001/items" }
      }
    },
    "links": {
      "self": "/v1/orders/ord_5001",
      "pay": "/v1/orders/ord_5001/payment"
    }
  },
  "included": [
    {
      "type": "customers",
      "id": "cus_842",
      "attributes": { "name": "Marta García", "email": "[email protected]" }
    }
  ]
}

Noticeable differences compared with HAL: the data goes under data with an explicit type and id, attributes are separated from relationships, and included allows complete related resources to be sent in the same response (which attacks the same "too many requests" problem that motivates GraphQL). The price is verbosity and a real learning curve.

Siren

It goes one step further and models actions with all their details: method, content type and expected fields.

{
  "class": ["order"],
  "properties": { "id": "ord_5001", "status": "pending_payment", "totalEuros": 29.00 },
  "actions": [
    {
      "name": "pay",
      "title": "Pay order",
      "method": "POST",
      "href": "/v1/orders/ord_5001/payment",
      "type": "application/json",
      "fields": [
        { "name": "paymentMethod", "type": "text" },
        { "name": "cardId", "type": "text" }
      ]
    }
  ],
  "links": [{ "rel": ["self"], "href": "/v1/orders/ord_5001" }]
}

With Siren, a generic client could generate a form from the response, just as a browser does with HTML. It is the most faithful to the spirit of HATEOAS and the least used in practice.

Comparison

Format Verbosity Standardises actions Learning curve Adoption
HAL Low No (links only) Gentle High
JSON:API High Partially Medium-high Medium, with a good ecosystem
Siren Medium Yes, with fields Medium Low

There is also a minimal alternative with no specific format: the HTTP Link header, standardised in RFC 8288 and widely used for pagination:

Link: </v1/coffees?page=3>; rel="next", </v1/coffees?page=1>; rel="first"

It is low-cost hypermedia and perfectly legitimate. We will come back to it when we talk about pagination in 02-06.

  1. The honest debate: why does almost nobody reach level 3?

Fielding was blunt in 2008: an API without hypermedia controls is not REST. And yet almost no commercial API you use day to day implements HATEOAS fully. It is worth understanding why, without falling into either dogmatism or disdain.

Arguments in favour of HATEOAS:

  • It decouples the client from the URLs, letting you reorganise them without breaking anything.
  • It centralises business logic on the server: the state machine is not replicated in every client.
  • Discoverability: a new developer can explore the API by navigating from the root.
  • It is especially valuable with many clients you do not control and flows with complex states.

Arguments against, or at least qualifying it:

  • Real clients are not generic. Aroma Mobile has a screen designed specifically for paying an order; whether the pay link is present or not does not save the app from having to know that flow, its fields and its design.
  • There is no universal client. The browser works because HTML defines links and forms in a standard way and there is a human interpreting them. With JSON there is no equivalent: the client needs to know that rel: "pay" means pay, which reintroduces semantic coupling.
  • Implementation and maintenance cost on both sides: generating links conditioned by state and permissions is not trivial.
  • Heavier responses, with an impact on mobile clients.
  • Consumer teams tend to ignore the links and go on building URLs by hand, so you pay the cost without getting the benefit.
  • Scarce tooling: compared with the OpenAPI ecosystem, support for hypermedia is limited.

The industry's position, which is also the one this course takes:

  • Level 2 is the de facto professional standard. A well-built level 2 API — clean URIs, correct verbs, meaningful status codes, caching and well-modelled errors — is an excellent API.
  • Adding partial hypermedia is cheap and worthwhile: a self link, pagination links and links to related resources deliver real value at minimal cost. It is what nearly everyone does, and it is what we will do with Aroma Store.
  • Full HATEOAS is reserved for cases with rich state flows and diverse consumers: payment gateways, open banking, long-lived government APIs.

  1. Criteria for deciding your level

Concrete questions to place yourself:

Question If the answer is yes...
Do you control every client and its deployments? Level 2 with self links is enough
Do your resources have rich state machines (orders, payments, case files)? State-dependent links contribute a lot
Do you have dozens of external consumers you do not control? It is worth investing in hypermedia
Do you foresee reorganising your URLs in the future? Hypermedia protects that evolution
Are your clients mobile, with tight bandwidth? Watch out for the extra weight of the links
Do you already have well-maintained OpenAPI documentation? It covers a good part of discoverability

Aroma Store's decision: a solid level 2 with selective hypermedia. Specifically:

  • A self link on every resource.
  • Pagination links on collections (the Link header).
  • Links to related resources (customer, coffee) so that URLs never have to be composed.
  • State-dependent action links only on orders, which is where the state machine is real and where the internal panel and the app benefit.

It is a conscious decision, with its reasons written down. That is exactly what is expected of a professional design.

Common Mistakes and Tips

  • Treating the model as an exam. Nobody gives you a prize for reaching level 3. The prize goes to an API that works well and can be maintained.
  • Believing you are at level 2 because you use GET and POST. If you return 200 OK for errors or put verbs in your URIs, you are not.
  • Implementing _links without judgement. Always returning the same links, regardless of state and permissions, is decorative: it contributes nothing and adds weight.
  • Inventing your own hypermedia format. If you are going to do it, use HAL or JSON:API: you will get libraries, documentation and developers who already know them.
  • Mixing formats. Using HAL's _links alongside JSON:API's data/attributes structure confuses both tools and people.
  • Confusing HATEOAS with "returning absolute URLs". An imageUrl field is not a hypermedia control; a link with a relation (rel) expressing a possible transition is.
  • Tip: if you are starting today, aim for an impeccable level 2 and add self and pagination links from day one. Moving up later is easy; cleaning up a badly designed API is not.

Exercises

Exercise 1: diagnose the level

For each API, state which Richardson level it is at and justify it with two reasons:

  1. POST /service with body {"method":"listCoffees"}, response always 200 OK.
  2. GET /v1/coffees/cof_001 returns 200 OK; POST /v1/coffees returns 201 Created with Location; GET /v1/coffees/cof_999 returns 404 Not Found.
  3. POST /v1/coffees/search, POST /v1/coffees/create, POST /v1/coffees/delete, all with 200 OK.
  4. Like number 2, but every response includes _links with self, reviews and, if there is stock, addToCart.

Exercise 2: move up a level

This Aroma Store operation is at level 1. Rewrite it at full level 2 (request, status code and relevant headers), and explain each decision.

POST /v1/carts/crt_77 HTTP/1.1
Content-Type: application/json

{ "action": "removeItem", "coffeeId": "cof_002" }

Current response: 200 OK with {"success": true}.

Exercise 3: design state-dependent hypermedia controls

The Aroma Store review resource has three states: pending_moderation, published and rejected. The rules are:

  • A pending review can be approved or rejected (by a moderator only), and its author can edit it.
  • A published review can be replied to by the shop team and its author can delete it.
  • A rejected review allows no actions, but the reason can be seen.

Design the response in HAL format for all three states, as seen by a moderator.

Solutions

Solution 1

  1. Level 0. A single endpoint (/service), the operation goes in the body, everything by POST and always 200.
  2. Level 2. There are URIs per resource, methods are used according to their semantics and the status codes are correct (201 with Location, 404 when it does not exist). There are no links, so it is not level 3.
  3. Level 1. URIs exist under /v1/coffees, but the verb is still in the path and everything goes by POST with 200: neither the methods nor the codes are used.
  4. Level 3. It meets everything from level 2 and additionally incorporates hypermedia controls conditioned by state (the add-to-cart link only appears if there is stock).

Solution 2

DELETE /v1/carts/crt_77/items/cof_002 HTTP/1.1
Host: api.aromastore.example
Authorization: Bearer TOKEN
HTTP/1.1 204 No Content

Decisions:

  • The verb moves to the HTTP method: deleting is DELETE, not an action field in the body.
  • The cart item becomes a resource in its own right with an identifiable URI (/v1/carts/crt_77/items/cof_002), which also allows it to be looked up or its quantity modified with PATCH.
  • DELETE is idempotent: if the request is retried after a network failure, the result is the same. With the previous POST there was no such guarantee.
  • 204 No Content indicates success with no body, instead of a redundant {"success": true}. If we wanted to return the updated cart to save the client a request, 200 OK with the complete cart would also be correct: it is a legitimate design decision.
  • If the item does not exist, the response is 404 Not Found; if the cart belongs to another customer, 403 Forbidden.

Solution 3

State pending_moderation (moderator's view):

{
  "id": "rev_101",
  "coffeeId": "cof_001",
  "author": "Marta G.",
  "rating": 5,
  "comment": "Balanced and sweet.",
  "status": "pending_moderation",
  "_links": {
    "self":    { "href": "/v1/reviews/rev_101" },
    "coffee":  { "href": "/v1/coffees/cof_001" },
    "approve": { "href": "/v1/reviews/rev_101/approval", "method": "POST" },
    "reject":  { "href": "/v1/reviews/rev_101/rejection", "method": "POST" }
  }
}

State published:

{
  "id": "rev_101",
  "status": "published",
  "publishedAt": "2026-08-14T10:02:00Z",
  "_links": {
    "self":   { "href": "/v1/reviews/rev_101" },
    "coffee": { "href": "/v1/coffees/cof_001" },
    "reply":  { "href": "/v1/reviews/rev_101/replies", "method": "POST" }
  }
}

State rejected:

{
  "id": "rev_101",
  "status": "rejected",
  "rejectionReason": "Contains offensive language",
  "_links": {
    "self":   { "href": "/v1/reviews/rev_101" },
    "coffee": { "href": "/v1/coffees/cof_001" }
  }
}

Key points of the solution:

  • The links change with the state: that is where the value of HATEOAS lies. The moderation panel can render its buttons from the links without knowing the state machine.
  • The links also depend on who is asking: the author would see edit and delete instead of approve and reject. A hypermedia response reflects permissions, not just state.
  • Actions are modelled as sub-resources (/approval, /rejection, /replies) that you POST to, avoiding verbs in the URIs.
  • The self link is always there, and rejectionReason only appears when it makes sense.

Conclusion

The Richardson maturity model offers a precise vocabulary for talking about how RESTful an API is: from level 0 — one endpoint, everything POST, HTTP as a mere tunnel — to level 3, where responses include the hypermedia controls that guide the client. We have seen the same Aroma Store operation rewritten at all four levels and confirmed that the decisive leap is level 2: resources with their own URI, verbs with their semantics and correct status codes, which is where you get caching, idempotency and monitoring for free. HATEOAS delivers real decoupling and centralises the state machine, but it has a cost that many teams never recoup; that is why Aroma Store will adopt a solid level 2 with selective hypermedia, and with the reasons written down.

With the REST model now thoroughly understood, it is time to place it against the alternatives. In the next lesson, REST vs. SOAP, we will take a close look at the style that dominated enterprise web services: its XML envelope, its WSDL contract, its WS-* stack, and we will compare both approaches side by side on the same Aroma Store case to understand when each still makes sense today.

REST API Course: Principles of Designing and Developing RESTful APIs

Module 1: Introduction to RESTful APIs

Module 2: Designing RESTful APIs

Module 3: Building RESTful APIs

Module 4: Best Practices and Security

Module 5: Tools and Frameworks

Module 6: Case Studies and Projects

© Copyright 2026. All rights reserved