When a client calls your API, they expect more than just data — they expect context. Did the request succeed? Was something created? Did validation fail? Was the resource not found? Is the server broken?
HTTP status codes answer all of these questions. They’re not just numbers — they’re a contract between your API and its consumers. Return the wrong code, and clients will misinterpret what happened. Return the right code, and your API becomes intuitive, predictable, and professional.
I’ve seen APIs return 200 OK for everything — including errors. The response body would say "success": false, forcing clients to parse JSON just to know if something went wrong. That’s not how HTTP works, and it creates unnecessary friction for everyone consuming your API.
In this article, we’ll cover the HTTP status codes you’ll actually use in ASP.NET Core Web APIs, when to use each one, and how to return them properly in both Minimal APIs and Controllers.
Let’s get into it.
Why HTTP Status Codes Matter
HTTP status codes are a universal language. Every HTTP client — browsers, mobile apps, Postman, curl, other microservices — understands them. They’re part of the HTTP specification, not something you invented.
When you return proper status codes:
- Clients can react correctly without parsing the response body first
- Monitoring tools can track error rates automatically (4xx vs 5xx)
- Load balancers and proxies can make routing decisions
- API consumers know immediately what happened
- Documentation tools can generate accurate specs
When you don’t:
- Clients have to guess what happened
- Error tracking becomes unreliable
- Your API feels unprofessional and hard to integrate with
If you’re returning
200 OKwith"error": truein the body, you’re doing it wrong.
HTTP Status Code Categories
Before diving into specific codes, let’s understand the five categories:
| Range | Category | Meaning |
|---|---|---|
| 1xx | Informational | Request received, continuing process |
| 2xx | Success | Request was successfully received, understood, and accepted |
| 3xx | Redirection | Further action needed to complete the request |
| 4xx | Client Error | Request contains bad syntax or cannot be fulfilled |
| 5xx | Server Error | Server failed to fulfill a valid request |
For Web APIs, you’ll primarily work with 2xx, 4xx, and 5xx. Let’s break down the codes you’ll use most often.
Success Codes (2xx)
These indicate the request was successful. But “successful” can mean different things.
200 OK
When to use: The request succeeded, and you’re returning data.
This is the most common success code. Use it when:
- Returning a resource (GET)
- Returning the result of a successful operation
- Returning search/query results
Minimal API:
app.MapGet("/products/{id}", async (int id, AppDbContext db) =>{ var product = await db.Products.FindAsync(id); return product is not null ? Results.Ok(product) : Results.NotFound();});Controller:
[HttpGet("{id}")]public async Task<IActionResult> GetProduct(int id){ var product = await _db.Products.FindAsync(id); return product is not null ? Ok(product) : NotFound();}201 Created
When to use: A new resource was created successfully.
This is specifically for POST requests that create something new. Always include:
- The
Locationheader pointing to the new resource - The created resource in the response body (optional but recommended)
Minimal API:
app.MapPost("/products", async (CreateProductRequest request, AppDbContext db) =>{ var product = new Product { Name = request.Name, Price = request.Price }; db.Products.Add(product); await db.SaveChangesAsync();
return Results.Created($"/products/{product.Id}", product);});Controller:
[HttpPost]public async Task<IActionResult> CreateProduct(CreateProductRequest request){ var product = new Product { Name = request.Name, Price = request.Price }; _db.Products.Add(product); await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);}The CreatedAtAction helper is powerful — it automatically generates the Location header based on your route.
204 No Content
When to use: The request succeeded, but there’s nothing to return.
Perfect for:
- DELETE operations (resource deleted, nothing to return)
- PUT/PATCH operations where you don’t need to return the updated resource
- Operations that succeed silently
Minimal API:
app.MapDelete("/products/{id}", async (int id, AppDbContext db) =>{ var product = await db.Products.FindAsync(id); if (product is null) return Results.NotFound();
db.Products.Remove(product); await db.SaveChangesAsync();
return Results.NoContent();});Controller:
[HttpDelete("{id}")]public async Task<IActionResult> DeleteProduct(int id){ var product = await _db.Products.FindAsync(id); if (product is null) return NotFound();
_db.Products.Remove(product); await _db.SaveChangesAsync();
return NoContent();}Common mistake: Returning
200 OKwith an empty body. Use204 No Contentinstead — it explicitly signals “success, nothing to return.”
202 Accepted
When to use: The request was accepted for processing, but processing hasn’t completed yet.
This is for asynchronous operations where the client shouldn’t wait for completion:
app.MapPost("/reports/generate", async (GenerateReportRequest request, IMessageQueue queue) =>{ var jobId = Guid.NewGuid(); await queue.EnqueueAsync(new GenerateReportJob(jobId, request));
return Results.Accepted($"/reports/status/{jobId}", new { JobId = jobId });});The client can then poll the status endpoint to check progress.
Client Error Codes (4xx)
These indicate the client made a mistake. The request was malformed, unauthorized, or asking for something that doesn’t exist.
400 Bad Request
When to use: The request is malformed or invalid.
This is the catch-all for client mistakes that don’t fit other 4xx codes:
- Missing required fields
- Invalid JSON syntax
- Invalid data types (string instead of number)
- Business rule violations that aren’t validation errors
Minimal API:
app.MapPost("/orders", async (CreateOrderRequest request, AppDbContext db) =>{ if (request.Items is null || request.Items.Count == 0) return Results.BadRequest(new { Error = "Order must contain at least one item" });
// Process order... return Results.Created($"/orders/{order.Id}", order);});Controller:
[HttpPost]public async Task<IActionResult> CreateOrder(CreateOrderRequest request){ if (request.Items is null || request.Items.Count == 0) return BadRequest(new { Error = "Order must contain at least one item" });
// Process order... return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);}401 Unauthorized
When to use: The client is not authenticated.
Despite the confusing name, 401 means “you need to authenticate” — not “you’re not authorized.” Common scenarios:
- Missing authentication token
- Expired token
- Invalid credentials
// ASP.NET Core handles this automatically with [Authorize][Authorize][HttpGet("profile")]public IActionResult GetProfile(){ return Ok(_currentUser.Profile);}If the request has no valid authentication, ASP.NET Core returns 401 Unauthorized automatically.
403 Forbidden
When to use: The client is authenticated but not authorized.
The client proved who they are, but they don’t have permission for this resource:
- User trying to access admin endpoints
- User trying to access another user’s data
- Insufficient role or permissions
[HttpDelete("users/{id}")]public async Task<IActionResult> DeleteUser(int id){ if (!User.IsInRole("Admin")) return Forbid();
// Delete user... return NoContent();}Remember:
401= “Who are you?” |403= “I know who you are, but you can’t do this.”
404 Not Found
When to use: The requested resource doesn’t exist.
This is straightforward — the client asked for something that isn’t there:
app.MapGet("/products/{id}", async (int id, AppDbContext db) =>{ var product = await db.Products.FindAsync(id); return product is not null ? Results.Ok(product) : Results.NotFound(new { Error = $"Product with ID {id} not found" });});You can return 404 with or without a body. Including a message helps clients understand what wasn’t found.
409 Conflict
When to use: The request conflicts with the current state of the resource.
Common scenarios:
- Trying to create a resource that already exists
- Optimistic concurrency failures
- State machine violations (e.g., canceling an already-shipped order)
app.MapPost("/users", async (CreateUserRequest request, AppDbContext db) =>{ var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Email == request.Email); if (existingUser is not null) return Results.Conflict(new { Error = "A user with this email already exists" });
// Create user... return Results.Created($"/users/{user.Id}", user);});Controller for optimistic concurrency:
[HttpPut("{id}")]public async Task<IActionResult> UpdateProduct(int id, UpdateProductRequest request){ var product = await _db.Products.FindAsync(id); if (product is null) return NotFound();
if (product.Version != request.Version) return Conflict(new { Error = "The product was modified by another user" });
// Update product... return NoContent();}422 Unprocessable Entity
When to use: The request is syntactically correct but semantically invalid.
This is for validation errors — the JSON is valid, but the data doesn’t meet business rules:
app.MapPost("/products", async (CreateProductRequest request, AppDbContext db) =>{ var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.Name)) errors["name"] = ["Name is required"];
if (request.Price <= 0) errors["price"] = ["Price must be greater than zero"];
if (errors.Count > 0) return Results.UnprocessableEntity(new { Errors = errors });
// Create product... return Results.Created($"/products/{product.Id}", product);});400 vs 422: Use
400for malformed requests (bad JSON, wrong types). Use422for valid requests with invalid data (failed validation rules).
ASP.NET Core’s built-in validation with [ApiController] returns 400 Bad Request by default, but many APIs prefer 422 for validation errors. You can customize this behavior.
429 Too Many Requests
When to use: The client has sent too many requests in a given time period.
This is for rate limiting:
// With rate limiting middlewarebuilder.Services.AddRateLimiter(options =>{ options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("fixed", config => { config.Window = TimeSpan.FromMinutes(1); config.PermitLimit = 100; });});Include Retry-After header to tell clients when they can retry.
Server Error Codes (5xx)
These indicate the server made a mistake. The request was valid, but the server couldn’t process it.
500 Internal Server Error
When to use: An unexpected error occurred on the server.
This is the catch-all for server-side failures:
- Unhandled exceptions
- Database connection failures
- External service failures (sometimes)
app.MapGet("/reports/{id}", async (int id, IReportService reportService) =>{ try { var report = await reportService.GenerateAsync(id); return Results.Ok(report); } catch (Exception ex) { // Log the exception return Results.Problem( title: "An error occurred while generating the report", statusCode: 500 ); }});Never expose exception details to clients in production. Log them internally, return a generic message externally.
502 Bad Gateway
When to use: Your server received an invalid response from an upstream service.
This is common in microservices when a downstream service returns garbage:
app.MapGet("/orders/{id}", async (int id, IOrderService orderService) =>{ try { var order = await orderService.GetOrderAsync(id); return Results.Ok(order); } catch (InvalidResponseException) { return Results.Problem( title: "Invalid response from order service", statusCode: 502 ); }});503 Service Unavailable
When to use: The server is temporarily unable to handle the request.
Common scenarios:
- Server is overloaded
- Server is under maintenance
- Dependency is down
app.MapGet("/health/ready", async (IHealthCheckService healthCheck) =>{ var isReady = await healthCheck.IsReadyAsync(); return isReady ? Results.Ok(new { Status = "Ready" }) : Results.Problem( title: "Service is temporarily unavailable", statusCode: 503 );});Include Retry-After header when possible.
504 Gateway Timeout
When to use: An upstream service didn’t respond in time.
app.MapGet("/search", async (string query, ISearchService searchService, CancellationToken ct) =>{ try { var results = await searchService.SearchAsync(query, ct); return Results.Ok(results); } catch (TaskCanceledException) { return Results.Problem( title: "Search service timed out", statusCode: 504 ); }});Returning Status Codes in ASP.NET Core
Minimal APIs
Minimal APIs use the Results class:
Results.Ok(data) // 200Results.Created(uri, data) // 201Results.NoContent() // 204Results.Accepted(uri, data) // 202Results.BadRequest(error) // 400Results.Unauthorized() // 401Results.Forbid() // 403Results.NotFound(error) // 404Results.Conflict(error) // 409Results.UnprocessableEntity(error) // 422Results.Problem(...) // 500 (or custom)For custom status codes:
Results.StatusCode(418) // I'm a teapotControllers
Controllers use helper methods or return IActionResult:
Ok(data) // 200Created(uri, data) // 201CreatedAtAction(action, data) // 201 with Location headerNoContent() // 204Accepted(uri, data) // 202BadRequest(error) // 400Unauthorized() // 401Forbid() // 403NotFound(error) // 404Conflict(error) // 409UnprocessableEntity(error) // 422StatusCode(500, error) // Custom status codeTypedResults (Minimal APIs)
.NET also provides TypedResults — a strongly-typed alternative to Results. The difference? TypedResults returns concrete types instead of IResult, enabling better OpenAPI documentation and compile-time type safety.
// Results returns IResult (less type information)app.MapGet("/products/{id}", async (int id, AppDbContext db) =>{ var product = await db.Products.FindAsync(id); return product is not null ? Results.Ok(product) : Results.NotFound();});
// TypedResults returns concrete types (better for OpenAPI)app.MapGet("/products/{id}", async Task<Results<Ok<Product>, NotFound>> (int id, AppDbContext db) =>{ var product = await db.Products.FindAsync(id); return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound();});Why use TypedResults?
- Better OpenAPI generation — The return type
Results<Ok<Product>, NotFound>tells Swagger/Scalar exactly what responses are possible - Compile-time safety — You can’t accidentally return a status code not declared in the return type
- Self-documenting code — The method signature shows all possible outcomes
Available TypedResults:
TypedResults.Ok(data) // Ok<T>TypedResults.Created(uri, data) // Created<T>TypedResults.CreatedAtRoute(route, data) // CreatedAtRoute<T>TypedResults.NoContent() // NoContentTypedResults.Accepted(uri, data) // Accepted<T>TypedResults.BadRequest(error) // BadRequest<T>TypedResults.NotFound(error) // NotFound<T>TypedResults.Conflict(error) // Conflict<T>TypedResults.UnprocessableEntity(error) // UnprocessableEntity<T>TypedResults.Problem(details) // ProblemHttpResultTypedResults.ValidationProblem(errors) // ValidationProblemCombining multiple return types:
app.MapPost("/products", async Task<Results<Created<Product>, ValidationProblem, Conflict>> (CreateProductRequest request, AppDbContext db) =>{ // Validation failed if (string.IsNullOrWhiteSpace(request.Name)) return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required"] });
// Conflict - already exists if (await db.Products.AnyAsync(p => p.Sku == request.Sku)) return TypedResults.Conflict();
// Success var product = new Product { Name = request.Name, Sku = request.Sku }; db.Products.Add(product); await db.SaveChangesAsync();
return TypedResults.Created($"/products/{product.Id}", product);});When to use which? Use
Resultsfor simple endpoints. UseTypedResultswhen you want precise OpenAPI documentation or when the endpoint has multiple possible response types.
Combining with ProblemDetails
For consistent error responses, combine status codes with ProblemDetails:
app.MapGet("/products/{id}", async (int id, AppDbContext db) =>{ var product = await db.Products.FindAsync(id); if (product is null) { return Results.Problem( title: "Product not found", detail: $"No product exists with ID {id}", statusCode: 404, instance: $"/products/{id}" ); }
return Results.Ok(product);});This returns:
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4", "title": "Product not found", "status": 404, "detail": "No product exists with ID 123", "instance": "/products/123"}Quick Reference Table
| Code | Name | When to Use |
|---|---|---|
| 200 | OK | Returning data successfully |
| 201 | Created | New resource created (include Location header) |
| 204 | No Content | Success with no body (DELETE, some PUT/PATCH) |
| 202 | Accepted | Async operation accepted for processing |
| 400 | Bad Request | Malformed request (bad JSON, wrong types) |
| 401 | Unauthorized | Not authenticated |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn’t exist |
| 409 | Conflict | Request conflicts with current state |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
| 502 | Bad Gateway | Invalid upstream response |
| 503 | Service Unavailable | Server temporarily unavailable |
| 504 | Gateway Timeout | Upstream service timeout |
Common Mistakes to Avoid
1. Returning 200 for Everything
Wrong:
return Ok(new { Success = false, Error = "User not found" });Right:
return NotFound(new { Error = "User not found" });2. Confusing 401 and 403
401= Not authenticated (no token, expired token)403= Authenticated but not authorized (valid token, insufficient permissions)
3. Using 500 for Client Errors
If the client sent bad data, that’s a 4xx error, not 500. Reserve 500 for genuine server failures.
4. Ignoring Status Codes in Error Responses
Don’t just return a JSON error message. Always set the appropriate HTTP status code — clients depend on it.
5. Not Using 201 for Resource Creation
When creating resources, use 201 Created with a Location header, not 200 OK.
Wrap-Up
HTTP status codes are fundamental to building professional Web APIs. They’re not optional — they’re part of the HTTP contract that clients depend on.
Key takeaways:
- 2xx = Success (200, 201, 204, 202)
- 4xx = Client error (400, 401, 403, 404, 409, 422, 429)
- 5xx = Server error (500, 502, 503, 504)
- Use 201 with
Locationheader for resource creation - Use 204 for successful operations with no body
- Combine status codes with ProblemDetails for consistent error responses
- Never return 200 for errors
When your API returns the right status codes, clients can trust it. Monitoring tools can track it. Developers can integrate with it. That’s the foundation of a well-designed API.
This article is part of our FREE .NET Web API Zero to Hero Series → Start the course here
If you found this helpful, share it with your dev team — and if there’s a status code scenario you’ve struggled with, drop a comment and let me know.
Happy Coding :)


