REST API Engineering · Lesson 4 of 19
Stop Returning 200 for Everything — HTTP Status Done Right
One of the easiest ways to tell a junior API from a senior one: check what status codes it returns. If everything comes back as 200 OK — even errors — that's a problem.
HTTP status codes are part of the API contract. They tell clients what happened without them needing to parse the body.
The Groups
| Range | Meaning |
|-------|---------|
| 2xx | Success |
| 3xx | Redirect |
| 4xx | Client made a mistake |
| 5xx | Server made a mistake |
The Codes You'll Actually Use
200 OK
Success with a response body. Use for GET, PUT, PATCH.
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetById(int id, CancellationToken ct)
{
var order = await _db.Orders.FindAsync(id, ct);
if (order is null) return NotFound();
return Ok(new OrderDto(order));
}201 Created
Resource was created. Always include the new resource in the body and a Location header pointing to it.
[HttpPost]
public async Task<IActionResult> Create(CreateOrderDto dto, CancellationToken ct)
{
var order = await _orderService.CreateAsync(dto, ct);
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
// Sets Location: /api/orders/42
}204 No Content
Success, nothing to return. Use for DELETE and PUT/PATCH when you don't return the updated resource.
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var order = await _db.Orders.FindAsync(id, ct);
if (order is null) return NotFound();
_db.Orders.Remove(order);
await _db.SaveChangesAsync(ct);
return NoContent();
}400 Bad Request
The client sent malformed input — wrong types, missing required fields, format errors.
[HttpPost]
public IActionResult Create(CreateOrderDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState); // or ValidationProblem(ModelState)
// ...
}With FluentValidation this happens automatically via the pipeline behavior.
401 Unauthorized
The request has no valid authentication. No token, expired token, invalid signature.
// ASP.NET Core returns 401 automatically when [Authorize] is used and no valid token is present
[Authorize]
[HttpGet("profile")]
public IActionResult GetProfile() => Ok(_currentUser.Profile);403 Forbidden
Authenticated but not allowed. The user is known but doesn't have permission.
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
// Returns 403 if authenticated user is not in Admin role
}The difference matters: 401 = "who are you?", 403 = "I know who you are, but no."
404 Not Found
The resource doesn't exist.
var order = await _db.Orders.FindAsync(id, ct);
if (order is null)
return NotFound(); // or NotFound(new { message = $"Order {id} not found" })409 Conflict
The request is valid but conflicts with current state — duplicate email, optimistic concurrency violation, trying to cancel an already-shipped order.
var existing = await _db.Users.FirstOrDefaultAsync(u => u.Email == dto.Email, ct);
if (existing is not null)
return Conflict(new { message = "Email already registered." });422 Unprocessable Entity
Valid input format, but business rules say no. Different from 400 — the structure is fine, but the action can't be performed.
var order = await _db.Orders.FindAsync(id, ct);
if (order.Status == OrderStatus.Shipped)
return UnprocessableEntity(new { message = "Cannot cancel a shipped order." });500 Internal Server Error
Something unexpected went wrong on the server. Never return this manually — let the global exception handler do it.
// Program.cs — global exception handler returns 500 automatically
app.UseExceptionHandler("/error");
// or
app.UseExceptionHandler(); // with ProblemDetails middlewareQuick Cheat Sheet
GET success → 200
POST success (created) → 201 + Location header
PUT success → 200 (with body) or 204
PATCH success → 200 (with body) or 204
DELETE success → 204
Bad input / validation → 400
Not authenticated → 401
Authenticated, no permission → 403
Resource not found → 404
State conflict / duplicate → 409
Business rule violation → 422
Server crash → 500 (automatic)Wire Up ProblemDetails
All error responses should use RFC 9457 ProblemDetails format, not raw strings.
// Program.cs
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
app.UseStatusCodePages();Now NotFound(), BadRequest(), Conflict() etc. all return:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Order 42 was not found.",
"instance": "/api/orders/42"
}Consistent across every endpoint, without any extra code.