Web APIs & REST: Building HTTP-Based Services the Right Way
A practical guide to REST principles and building well-designed HTTP APIs — covering resource modeling, HTTP methods and status codes, versioning, pagination, error handling, HATEOAS, and common anti-patterns, with ASP.NET Core examples.
Table of Contents
- Introduction
- What REST Actually Means
- Resources and URIs
- HTTP Methods
- HTTP Status Codes
- Request and Response Design
- Filtering, Sorting, and Pagination
- Versioning
- Error Handling
- HATEOAS and the Richardson Maturity Model
- Idempotency and Caching
- Security Basics
- Common Anti-Patterns
- Quick Reference Table
- Conclusion
Introduction
REST (Representational State Transfer) isn't a protocol or a library — it's an architectural style, defined by Roy Fielding in his 2000 doctoral dissertation, for building distributed systems that communicate over HTTP. In practice, "REST API" has become shorthand for "an HTTP API that models data as resources and uses HTTP methods and status codes meaningfully" — which is a looser, more pragmatic interpretation than Fielding's original constraints, but it's the one that dominates real-world API design.
This guide covers both the pragmatic conventions most teams follow day-to-day and the stricter theory behind them, so you understand not just what to do but why.
1. What REST Actually Means
Fielding's dissertation defines REST via a set of architectural constraints:
- Client-server — separation of concerns between UI/client and data storage/server.
- Statelessness — each request contains all the information needed to process it; the server holds no client session state between requests.
- Cacheability — responses must define themselves as cacheable or not, so clients (and intermediaries) can reuse them safely.
- Uniform interface — the core REST constraint, broken into four sub-constraints: resource identification (URIs), manipulation through representations (JSON/XML bodies), self-descriptive messages (methods, status codes, media types), and HATEOAS (hypermedia as the engine of application state — see Section 9).
- Layered system — a client can't tell (and shouldn't need to know) whether it's talking directly to the server or through intermediaries like load balancers or gateways.
- Code on demand (optional) — servers can extend client functionality by transferring executable logic (rarely used in practice for APIs).
Most "REST APIs" in the wild satisfy constraints 1–3 and 5 fully, and part of 4 (resources + HTTP methods) — but skip HATEOAS almost entirely. That's fine; it's a spectrum, not a certification (see the Richardson Maturity Model in Section 9).
2. Resources and URIs
The foundational idea of REST API design: everything is a resource, identified by a URI, and you interact with it through a small, fixed set of HTTP methods rather than inventing a new "verb" endpoint for every action.
Nouns, not verbs
❌ GET /getAllProducts
❌ POST /createNewOrder
❌ POST /updateProductPrice
✅ GET /products
✅ POST /orders
✅ PATCH /products/{id}
The HTTP method already carries the verb — putting another verb in the URL is redundant and usually a sign the API is modeled around actions (RPC-style) rather than resources.
Collections and individual resources
GET /products → list of products (collection)
POST /products → create a new product
GET /products/{id} → a single product
PUT /products/{id} → replace a single product
PATCH /products/{id} → partially update a single product
DELETE /products/{id} → delete a single product
Nested resources for relationships
GET /customers/{id}/orders → orders belonging to a customer
GET /orders/{id}/line-items → line items belonging to an order
POST /customers/{id}/orders → create an order for a specific customer
Nesting should reflect genuine ownership/containment, not just "these are related." A rule of thumb: if the child resource can't sensibly exist without the parent (an order line item without an order), nest it; if it can (a product exists independent of any one customer), avoid deep nesting like /customers/{id}/products.
Naming conventions
- Use plural nouns for collections:
/products, not/product. - Use lowercase, hyphen-separated paths:
/order-items, not/orderItemsor/OrderItems. - Avoid trailing slashes and file extensions:
/products/42, not/products/42.json.
3. HTTP Methods
Each HTTP method carries specific semantics that clients, proxies, and caches all rely on — using them correctly is what makes an API predictable.
| Method | Purpose | Safe? | Idempotent? | Has a body? |
|---|---|---|---|---|
GET |
Retrieve a resource or collection | Yes | Yes | No |
POST |
Create a resource, or trigger a non-idempotent action | No | No | Yes |
PUT |
Replace a resource entirely | No | Yes | Yes |
PATCH |
Partially update a resource | No | Not guaranteed | Yes |
DELETE |
Remove a resource | No | Yes | Usually no |
HEAD |
Like GET, but headers only, no body | Yes | Yes | No |
OPTIONS |
Discover allowed methods/CORS preflight | Yes | Yes | No |
-
Safe means the method doesn't change server state — a
GETshould never have side effects a client wouldn't expect. -
Idempotent means calling it once has the same effect as calling it many times —
DELETE /products/5called twice still ends with the product gone (the second call may 404, which is fine — the end state is identical).
PUT vs. PATCH
// PUT: full replacement — every field must be provided
app.MapPut("/products/{id}", (int id, Product fullProduct) =>
{
// fullProduct completely replaces the existing resource
});
// PATCH: partial update — only supplied fields change
app.MapPatch("/products/{id}", (int id, JsonPatchDocument<Product> patch) =>
{
// apply only the specified operations
});
A common PATCH body format is JSON Patch (RFC 6902):
[
{ "op": "replace", "path": "/price", "value": 29.99 },
{ "op": "add", "path": "/tags/-", "value": "sale" }
]
A simpler, widely-used alternative is a "merge patch" style — a partial JSON object where only present fields are updated (RFC 7386). Either is fine; pick one and document it consistently.
POST for actions that don't fit CRUD
Not everything maps neatly to create/read/update/delete. For actions like "publish this article" or "refund this order," a POST to an action-like sub-resource is an accepted, pragmatic pattern:
POST /orders/{id}/refund
POST /articles/{id}/publish
This is a small, deliberate exception to "nouns not verbs" — it's better than misusing PATCH to smuggle in behavior that isn't really a field update.
4. HTTP Status Codes
Status codes are part of the API's contract — clients should be able to branch on them without parsing the response body.
2xx — Success
| Code | Meaning | When to use |
|---|---|---|
200 OK |
Success |
GET, PUT, PATCH success with a body |
201 Created |
Resource created |
POST that creates a resource — include a Location header |
202 Accepted |
Accepted for async processing | Long-running operations queued for later |
204 No Content |
Success, no body |
DELETE, or PUT/PATCH with nothing to return |
4xx — Client errors
| Code | Meaning | When to use |
|---|---|---|
400 Bad Request |
Malformed request | Invalid JSON, missing required fields |
401 Unauthorized |
Missing/invalid credentials | No valid auth token provided |
403 Forbidden |
Authenticated but not allowed | Valid token, insufficient permissions |
404 Not Found |
Resource doesn't exist | Unknown ID, or intentionally hiding existence for security |
405 Method Not Allowed |
Wrong HTTP method for this route |
DELETE on a read-only resource |
409 Conflict |
State conflict | Duplicate unique key, version mismatch |
422 Unprocessable Entity |
Semantically invalid | Well-formed JSON that fails business validation |
429 Too Many Requests |
Rate limit exceeded | Client is being throttled |
5xx — Server errors
| Code | Meaning | When to use |
|---|---|---|
500 Internal Server Error |
Unhandled exception | Generic catch-all — avoid leaking stack traces |
502 Bad Gateway |
Upstream failure | A dependency/proxy returned an invalid response |
503 Service Unavailable |
Temporarily down | Maintenance, overload, circuit breaker open |
504 Gateway Timeout |
Upstream timeout | A dependency took too long to respond |
201 Created in practice
app.MapPost("/products", (CreateProductRequest request, IProductRepository repo) =>
{
var product = repo.Add(request);
return Results.Created($"/products/{product.Id}", product);
});
The Location header (/products/{id}) tells the client exactly where to find the resource it just created — a small detail that's easy to skip but genuinely useful to clients.
5. Request and Response Design
Consistent envelope (or the lack of one)
For a single resource, return the resource directly:
{
"id": 42,
"name": "Wireless Mouse",
"price": 29.99
}
For a collection, avoid returning a bare array at the top level — it makes adding pagination metadata later a breaking change. Wrap it:
{
"data": [
{ "id": 42, "name": "Wireless Mouse", "price": 29.99 },
{ "id": 43, "name": "Mechanical Keyboard", "price": 89.99 }
],
"page": 1,
"pageSize": 20,
"totalCount": 137
}
Naming: camelCase vs. snake_case
Pick one casing convention and apply it consistently across the whole API — mixing productId and product_id in the same payload is a common source of client-side bugs. JSON APIs most commonly use camelCase (matching JavaScript conventions), which is also System.Text.Json's default in ASP.NET Core.
Dates and times
Always use ISO 8601 (2026-07-05T14:30:00Z), always in UTC unless there's a specific reason not to, and always explicit about the timezone offset (Z or +00:00 — never a bare timestamp with no offset).
6. Filtering, Sorting, and Pagination
Filtering via query parameters
GET /products?category=electronics&minPrice=10&maxPrice=100
app.MapGet("/products", (string? category, decimal? minPrice, decimal? maxPrice, IProductRepository repo) =>
{
var query = repo.Query();
if (category is not null) query = query.Where(p => p.Category == category);
if (minPrice is not null) query = query.Where(p => p.Price >= minPrice);
if (maxPrice is not null) query = query.Where(p => p.Price <= maxPrice);
return query.ToList();
});
Sorting
GET /products?sort=price → ascending by price
GET /products?sort=-price → descending by price (leading `-`)
GET /products?sort=category,-price → sort by category, then price descending
Pagination: offset vs. cursor
Offset-based (simple, but can skip/duplicate rows under concurrent writes):
GET /products?page=2&pageSize=20
Cursor-based (stable under concurrent writes, better for large or real-time datasets):
GET /products?after=eyJpZCI6NDJ9&limit=20
{
"data": [ /* ... */ ],
"nextCursor": "eyJpZCI6NjJ9",
"hasMore": true
}
Offset pagination is fine for most admin dashboards and small datasets; cursor pagination is worth the extra complexity for large, frequently-changing, or infinite-scroll-style collections.
7. Versioning
APIs change over time, and clients need a way to keep working against an older contract while you evolve the newer one.
URI versioning (most common, most visible)
GET /v1/products
GET /v2/products
Simple, cache-friendly, and immediately visible in logs — the most widely adopted approach despite being technically "impure" (the URI is supposed to identify a resource, not a version).
Header versioning
GET /products
Accept: application/vnd.myapi.v2+json
Keeps URIs clean and stable, but is harder to test manually (can't just click a link) and less visible in server logs.
Query parameter versioning
GET /products?api-version=2.0
ASP.NET Core supports all three via the Asp.Versioning package:
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
});
Practical guidance
- Version from day one, even at
v1— retrofitting versioning onto an unversioned API is painful. - Prefer additive, backward-compatible changes (new optional fields, new endpoints) over bumping the version — reserve version bumps for breaking changes.
- Support old versions for a clearly communicated deprecation window, and surface deprecation via response headers (
Sunset,Deprecation) rather than silently removing them.
8. Error Handling
The Problem Details standard (RFC 9457)
ASP.NET Core has built-in support for ProblemDetails, a standardized JSON error format that gives clients a consistent shape to parse regardless of which endpoint failed:
{
"type": "https://example.com/errors/insufficient-stock",
"title": "Insufficient stock",
"status": 409,
"detail": "Only 3 units of 'Wireless Mouse' are available, but 5 were requested.",
"instance": "/orders",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00"
}
app.MapPost("/orders", (CreateOrderRequest request, IInventoryService inventory) =>
{
if (!inventory.HasStock(request.ProductId, request.Quantity))
{
return Results.Problem(
title: "Insufficient stock",
detail: $"Only {inventory.GetStock(request.ProductId)} units available.",
statusCode: StatusCodes.Status409Conflict);
}
// ... create order
});
Validation errors specifically
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["Name is required."],
"Price": ["Price must be greater than zero."]
}
}
ASP.NET Core produces this shape automatically for [ApiController]-based validation failures, and Results.ValidationProblem(...) produces the same shape from minimal API endpoints.
What NOT to put in error responses
- Stack traces or internal exception messages in production (log them server-side; return a generic message and a
traceIdclients can hand to support). - Different error shapes per endpoint — pick one format (ProblemDetails is a sound default) and use it everywhere.
-
200 OKwith an"error": truefield in the body — this breaks HTTP semantics and forces every client to parse the body just to know if the call succeeded.
9. HATEOAS and the Richardson Maturity Model
Leonard Richardson's maturity model describes REST adoption as a set of levels:
| Level | Characteristic | Example |
|---|---|---|
| 0 | Single endpoint, POST everything (RPC-over-HTTP) |
POST /api with an action name in the body |
| 1 | Multiple resource URIs, but one HTTP method (usually POST) |
POST /getProduct, POST /createOrder
|
| 2 | Resources + proper HTTP methods and status codes |
GET /products/{id}, 201 Created, 404 Not Found
|
| 3 | + HATEOAS: responses include links to related actions/resources | See below |
Most production "REST APIs" stop at level 2 — and that's a reasonable, pragmatic choice for the vast majority of use cases. Level 3 (true HATEOAS) means the client discovers available actions from the response itself, rather than hardcoding URL templates:
{
"id": 42,
"status": "pending",
"total": 59.98,
"_links": {
"self": { "href": "/orders/42" },
"cancel": { "href": "/orders/42/cancel", "method": "POST" },
"customer": { "href": "/customers/17" }
}
}
HATEOAS shines in large, evolving public APIs where clients genuinely benefit from discoverability (state transitions differ per resource state — a shipped order has no cancel link, for instance). For most internal or moderately-sized APIs, the added complexity on both client and server outweighs the benefit, which is why level 2 remains the practical industry default.
10. Idempotency and Caching
Idempotency keys for non-idempotent operations
POST isn't idempotent by HTTP definition, but retries (network blips, client timeouts) are a fact of life. An idempotency key lets a client safely retry a POST without risking a duplicate side effect (e.g., double-charging a payment):
POST /payments
Idempotency-Key: 7c9f8a3e-1b2d-4e5f-9a1b-2c3d4e5f6a7b
app.MapPost("/payments", async (PaymentRequest request, HttpContext ctx, IIdempotencyStore store) =>
{
var key = ctx.Request.Headers["Idempotency-Key"].ToString();
if (await store.TryGetCachedResponse(key) is { } cached)
return Results.Content(cached, "application/json");
var result = await ProcessPayment(request);
await store.SaveResponse(key, result);
return Results.Ok(result);
});
Caching headers
app.MapGet("/products/{id}", (int id, IProductRepository repo) =>
{
var product = repo.GetById(id);
return Results.Ok(product);
})
.CacheOutput(policy => policy.Expire(TimeSpan.FromMinutes(5)));
-
ETag— a hash/version identifier for a resource; clients send it back viaIf-None-Matchto get a cheap304 Not Modifiedif nothing changed. -
Cache-Control—max-age,no-store,private/publiccontrol how long and where a response can be cached. -
Last-Modified/If-Modified-Since— a simpler, time-based alternative to ETags.
11. Security Basics
- Always use HTTPS — never accept credentials or tokens over plain HTTP.
- Authenticate with bearer tokens (OAuth2/JWT), not API keys in query strings (query strings end up in logs, browser history, and referrer headers).
- Authorize per-resource, not just per-endpoint — verify the authenticated user actually owns/can access the specific resource ID in the URL, not just that they're logged in (a classic IDOR — insecure direct object reference — vulnerability is checking auth at the endpoint level but not the record level).
- Rate limit — protect against abuse and accidental retry storms (see the ASP.NET Core rate limiting middleware).
- Validate and sanitize all input — never trust query parameters, headers, or body content, even from authenticated clients.
-
Don't leak information via status codes — for genuinely private resources, consider
404instead of403when you don't want to confirm a resource's existence to unauthorized callers.
12. Common Anti-Patterns
| Anti-pattern | Why it's a problem | Better approach |
|---|---|---|
Verbs in URLs (/getProducts) |
Redundant with HTTP method, RPC-flavored | GET /products |
Returning 200 for errors |
Breaks HTTP semantics, forces body parsing | Use proper 4xx/5xx status codes |
Deeply nested URLs (/a/{id}/b/{id}/c/{id}/d/{id}) |
Hard to read, brittle, awkward for partial access | Flatten where the child can stand alone; use query filters instead |
| Bare arrays as top-level response | Breaks when you need to add pagination metadata | Wrap in an object: { "data": [...] }
|
| Inconsistent casing/naming | Increases client integration bugs | Pick one convention (usually camelCase) and enforce it |
| No versioning strategy | Any change risks breaking existing clients | Version from day one, prefer additive changes |
| Chatty APIs (many round-trips for one screen) | Latency, especially on mobile | Support field selection (?fields=) or a light aggregation/BFF layer |
| Leaking stack traces in errors | Security risk, unhelpful to clients | Use ProblemDetails with a generic message + trace ID |
Quick Reference Table
| Concept | Guidance |
|---|---|
| URI design | Plural nouns, no verbs, lowercase-hyphenated |
POST |
Create resources; 201 Created + Location header |
PUT |
Full replace; idempotent |
PATCH |
Partial update; JSON Patch or merge-patch body |
GET/DELETE
|
Safe/idempotent; no unexpected side effects |
| Collections | Wrap in an object with pagination metadata, never a bare array |
| Errors | Use ProblemDetails (RFC 9457) consistently across all endpoints |
| Versioning | Version from day one; URI versioning is the most common default |
| Pagination | Offset for small/simple data; cursor for large/real-time data |
| Idempotency | Use idempotency keys for retry-safe POST operations |
| HATEOAS | Optional; valuable for large public APIs, often skipped for internal ones |
Conclusion
Good REST API design isn't about chasing Fielding's dissertation to the letter — it's about making an HTTP API predictable: resources are nouns, HTTP methods carry the verbs, status codes tell the truth about what happened, and errors follow one consistent shape. Get those fundamentals right — plus sane pagination, a versioning strategy from day one, and proper use of caching/idempotency — and you'll have covered the vast majority of what makes an API pleasant to integrate against, regardless of whether you ever reach HATEOAS-level "true" REST.
The best test of an API's design is usually a simple one: could a new developer, given just the URL list and a bit of curiosity, guess correctly how most of it behaves? If yes, the fundamentals in this guide have probably been followed.
Found this useful? Feel free to star the repo, open an issue with corrections, or share the REST design decision you keep having to defend to your team.
Top comments (0)