90% of APIs Are Not RESTful: What You're Missing and When It Matters
Most APIs called RESTful are really Level 2. Learn what real REST means, the Richardson Maturity Model, HATEOAS in ASP.NET Core, and when it matters.
Ordering food at a restaurant
Imagine you walk into a small restaurant for the first time. You do not know the menu, the prices, or what is available today. So what do you do? You look at the menu card. The menu tells you what you can order right now. If a dish is sold out, it is crossed off. After you eat, the bill arrives with a line that says "Pay at the counter." You never had to guess. The restaurant kept telling you what you could do next.
Now imagine a different restaurant. There is no menu. You must already know every dish, every price, and exactly how to ask for the bill, all by heart. If the kitchen changes one dish, you would order the wrong thing and get confused.
These two restaurants are a perfect picture of two kinds of APIs. The first restaurant, with the helpful menu, is a truly RESTful API. The second, where you must memorize everything, is what most APIs really are. They work fine, but they are not the full REST that the inventor of REST described.
In this article we will see what real REST means, why most APIs miss the last step, and when that missing step actually matters in your ASP.NET Core projects.
Where the word REST comes from
REST stands for Representational State Transfer. A man named Roy Fielding described it in the year 2000 in his university thesis. He helped design how the web itself works, so he knew the subject deeply.
Fielding did not say "REST is using JSON over HTTP." He listed a set of rules, which we call constraints. If your API follows all of them, it is RESTful. If it skips some, it is "REST-ish" or "REST-style," even though almost everyone just says "RESTful" anyway.
Here are the main constraints in simple words.
| Constraint | Plain meaning |
|---|---|
| Client–Server | The client and server are separate and can change on their own. |
| Stateless | Each request carries everything the server needs. The server remembers nothing between requests. |
| Cacheable | Responses say whether they can be saved and reused. |
| Uniform Interface | Everyone uses the same simple rules: resources, verbs, and links. |
| Layered System | There can be caches, proxies, and gateways in the middle. |
| HATEOAS | The server's answers include links for what the client can do next. |
Most teams happily follow the first five. The last one, HATEOAS, is the one that almost everybody quietly drops. That single missing piece is why we can honestly say most APIs are not fully RESTful.
The Richardson Maturity Model: levels of REST
A man named Leonard Richardson came up with a simple way to grade how "RESTful" an API is. Martin Fowler later wrote it up and made it famous. It has four levels, from Level 0 to Level 3. Think of it as steps on a ladder.
Richardson Maturity Model
Steps
Level 0
One URL, one verb (POST). Just RPC over HTTP.
Level 1
Many resources, each with its own URL.
Level 2
Proper HTTP verbs and status codes.
Level 3
HATEOAS: responses include links for next steps.
Let us walk through each level with a tiny example: an online bookstore.
Level 0: one door for everything
At Level 0 you have a single endpoint, and you send everything to it, usually with POST. The body of the request decides what happens. This is really just remote procedure calls dressed up as HTTP. It is the least RESTful.
// Level 0: one endpoint does everything based on the body
app.MapPost("/api", (RpcRequest request) =>
{
return request.Action switch
{
"getBook" => Results.Ok(BookService.Get(request.Id)),
"createBook" => Results.Ok(BookService.Create(request.Book)),
_ => Results.BadRequest("Unknown action")
};
});Notice there is only one URL. The client must know the secret "action" words. The menu is hidden.
Level 1: give each thing its own address
At Level 1 we introduce resources. A book is a resource. A list of books is a resource. Each gets its own URL, like /books and /books/42. We stop cramming everything into one door.
But at Level 1 we might still use POST for everything, or ignore proper status codes. We have addresses, but not yet good manners.
Level 2: use HTTP the way it was meant
Level 2 is where most professional APIs live today. Here we use the right HTTP verbs and the right status codes.
| Verb | What it means | Example |
|---|---|---|
| GET | Read something | GET /books/42 |
| POST | Create something | POST /books |
| PUT | Replace something | PUT /books/42 |
| DELETE | Remove something | DELETE /books/42 |
We also return honest status codes: 200 OK when things go well, 201 Created after making something new, 404 Not Found when it is missing, and so on.
// Level 2: real verbs and real status codes
app.MapGet("/books/{id:int}", (int id) =>
{
var book = BookService.Get(id);
return book is null ? Results.NotFound() : Results.Ok(book);
});
app.MapPost("/books", (Book book) =>
{
var created = BookService.Create(book);
// 201 Created, with a Location header pointing to the new book
return Results.Created($"/books/{created.Id}", created);
});
app.MapDelete("/books/{id:int}", (int id) =>
{
BookService.Delete(id);
return Results.NoContent(); // 204
});This is clean and correct. When people say "RESTful API" in everyday work, they almost always mean this, a Level 2 API. And that is perfectly fine for the vast majority of projects.
Level 3: HATEOAS, the missing top step
Level 3 adds HATEOAS. Now every response carries links that tell the client what it can do next, just like the restaurant menu. The client does not build URLs by hand. It reads the links the server gives it and follows them.
Here is what a Level 3 response body might look like. The data is the same, but now it comes with a small set of links.
// Level 3: attach hypermedia links to the response
public record Link(string Href, string Rel, string Method);
public record BookDto(int Id, string Title, decimal Price, List<Link> Links);
app.MapGet("/books/{id:int}", (int id, HttpContext ctx) =>
{
var book = BookService.Get(id);
if (book is null) return Results.NotFound();
var baseUrl = $"{ctx.Request.Scheme}://{ctx.Request.Host}";
var dto = new BookDto(book.Id, book.Title, book.Price, new()
{
new Link($"{baseUrl}/books/{book.Id}", "self", "GET"),
new Link($"{baseUrl}/books/{book.Id}/reviews", "reviews", "GET"),
new Link($"{baseUrl}/books/{book.Id}", "delete", "DELETE")
});
return Results.Ok(dto);
});The client now sees self, reviews, and delete links. If the server later moves reviews to a new URL, the client still works, because it just follows whatever link the server sends. The server drives the navigation, not the client. That is the heart of HATEOAS.
Why 90% stop at Level 2
So if Level 3 is the "real" REST, why does almost nobody build it? There are honest, practical reasons.
Why teams skip HATEOAS
Steps
Cost
Building and testing links takes extra effort.
Clients
Most clients ignore links and hardcode URLs anyway.
Tooling
OpenAPI and code generators describe Level 2 well already.
Payload
Links make responses bigger for little benefit in small apps.
Let us look at each reason kindly.
The clients do not use the links. Most front-end teams and mobile teams read your API documentation, then hardcode the URLs into their code. They never look at the links you send. So the effort is wasted on them.
Good documentation already exists. Tools like OpenAPI (Swagger) describe a Level 2 API completely. A developer can read the docs, generate a client, and start working. HATEOAS was meant to reduce the need for out-of-band docs, but in practice, docs win.
It costs time. Every endpoint must now build the right links, with the right conditions. Can this order be cancelled? Only if it is not shipped yet. That logic must live in your link-building code, and you must test it.
Extra weight. Links make each response larger. For a small internal app, that is just noise.
None of these reasons make Level 2 "wrong." They simply explain why the industry settled there. It is a reasonable, grown-up trade-off.
When HATEOAS actually matters
Here is the honest part. HATEOAS is not useless. It is just overkill for most apps and genuinely valuable for a few. Knowing the difference is the real skill.
| Situation | Is HATEOAS worth it? |
|---|---|
| Small internal API, one team owns both sides | No, skip it. |
| A few clients you fully control | Usually no. |
| Large public API with many unknown clients | Often yes. |
| Long-lived API that will change a lot over years | Yes, links reduce breakage. |
| Workflow-heavy domain (orders, approvals, payments) | Yes, links express allowed next steps. |
Think about an orders API. An order can be paid, then shipped, then delivered. At each stage, different actions are allowed. With HATEOAS, the server simply includes a cancel link only when cancelling is allowed. The client does not need to memorize the rules. It just shows a "Cancel" button when the link is present.
That is the one case where HATEOAS feels natural: when the allowed actions change with the state of the resource. The links become a live, honest menu of what you can do right now.
A balanced way to think about it
You do not have to choose "pure REST or nothing." A kind, practical path looks like this.
- Build a clean Level 2 API by default. Use proper resources, verbs, and status codes. This already puts you ahead of many teams.
- Write great documentation with OpenAPI so any client can understand you.
- Add HATEOAS links only where they earn their keep, such as workflow endpoints where the allowed actions change.
- Be honest in how you talk about it. Calling your API "RESTful" is fine in daily speech, but knowing it is really Level 2 keeps you clear-headed.
The goal is not a gold star for purity. The goal is an API that is easy to use, easy to change, and honest about what it is.
A quick reality check on REST today
REST is still the most common style for web APIs, but it shares the stage now. Some teams use GraphQL when clients need to pick exactly which fields they want. Others use gRPC for fast service-to-service calls inside a system. And many "REST" APIs are really just Level 2 JSON-over-HTTP, which is completely acceptable.
None of this means REST is dead or that you did something wrong. It means you should pick the right tool for the job and use words honestly. When someone insists your API "is not real REST," you can smile and say: "Correct, it is a Level 2 API, and that is a deliberate choice."
Quick recap
- REST is a set of rules from Roy Fielding, not just "JSON over HTTP."
- The Richardson Maturity Model grades APIs from Level 0 to Level 3.
- Level 2 (resources, HTTP verbs, status codes) is what most people mean by "RESTful," and it is perfectly professional.
- Level 3 adds HATEOAS: responses include links telling the client what to do next, like a restaurant menu.
- Most APIs stop at Level 2, because clients ignore links, docs already exist, and links add cost and weight.
- HATEOAS matters most for large, public, long-lived, or workflow-heavy APIs where allowed actions change with state.
- In ASP.NET Core, you can add links by returning a DTO with a small list of
Linkobjects, only where it helps. - Calling your API "RESTful" in daily talk is fine. Just know, honestly, that it is usually Level 2.
References and further reading
- Richardson Maturity Model — Martin Fowler
- Implementing HATEOAS in ASP.NET Core Web API — Code Maze
- Understanding HATEOAS in RESTful APIs with C# and ASP.NET Core — Francesco Del Re
- HATEOAS in ASP.NET Core APIs: Should You Still Use It? — Kittikawin L.
- Richardson Maturity Model — REST API Tutorial
Related Posts
API Key Authentication in ASP.NET Core: The Secure Way
Learn how to add API key authentication to your ASP.NET Core API the right way. Use an AuthenticationHandler, hash keys, compare safely, and follow 2026 security best practices, with diagrams and code.
Rate Limiting in ASP.NET Core: A Simple, Complete Guide
Learn rate limiting in ASP.NET Core with simple examples. Understand fixed window, sliding window, token bucket, and concurrency limiters, with diagrams, code, and real-world advice on which to pick.
Caching in ASP.NET Core: Make Your App Fast (The Easy Way)
Learn caching in ASP.NET Core with simple examples. Understand in-memory cache, distributed Redis cache, HybridCache, and output cache, with diagrams, code, and clear advice on which to use and when.
API Versioning in ASP.NET Core: A Friendly, Complete Guide
Learn API versioning in ASP.NET Core with simple examples. URL, query string, header, and media type versioning explained with diagrams, code, and OpenAPI tips.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.