Free .NET Web API Course

Understanding HTTP Status Codes – Returning Proper API Responses in ASP.NET Core

Master HTTP status codes for your ASP.NET Core Web APIs. Learn when to use 200, 201, 204, 400, 401, 403, 404, 409, 422, 500, and more — with practical code examples for both Minimal APIs and Controllers.

dotnet webapi-course

rest webapi http api-design dotnet-webapi-zero-to-hero-course

12 min read
2.1K views

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 OK with "error": true in the body, you’re doing it wrong.

HTTP Status Code Categories

Before diving into specific codes, let’s understand the five categories:

RangeCategoryMeaning
1xxInformationalRequest received, continuing process
2xxSuccessRequest was successfully received, understood, and accepted
3xxRedirectionFurther action needed to complete the request
4xxClient ErrorRequest contains bad syntax or cannot be fulfilled
5xxServer ErrorServer 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 Location header 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 OK with an empty body. Use 204 No Content instead — 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 400 for malformed requests (bad JSON, wrong types). Use 422 for 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 middleware
builder.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) // 200
Results.Created(uri, data) // 201
Results.NoContent() // 204
Results.Accepted(uri, data) // 202
Results.BadRequest(error) // 400
Results.Unauthorized() // 401
Results.Forbid() // 403
Results.NotFound(error) // 404
Results.Conflict(error) // 409
Results.UnprocessableEntity(error) // 422
Results.Problem(...) // 500 (or custom)

For custom status codes:

Results.StatusCode(418) // I'm a teapot

Controllers

Controllers use helper methods or return IActionResult:

Ok(data) // 200
Created(uri, data) // 201
CreatedAtAction(action, data) // 201 with Location header
NoContent() // 204
Accepted(uri, data) // 202
BadRequest(error) // 400
Unauthorized() // 401
Forbid() // 403
NotFound(error) // 404
Conflict(error) // 409
UnprocessableEntity(error) // 422
StatusCode(500, error) // Custom status code

TypedResults (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?

  1. Better OpenAPI generation — The return type Results<Ok<Product>, NotFound> tells Swagger/Scalar exactly what responses are possible
  2. Compile-time safety — You can’t accidentally return a status code not declared in the return type
  3. 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() // NoContent
TypedResults.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) // ProblemHttpResult
TypedResults.ValidationProblem(errors) // ValidationProblem

Combining 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 Results for simple endpoints. Use TypedResults when 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

CodeNameWhen to Use
200OKReturning data successfully
201CreatedNew resource created (include Location header)
204No ContentSuccess with no body (DELETE, some PUT/PATCH)
202AcceptedAsync operation accepted for processing
400Bad RequestMalformed request (bad JSON, wrong types)
401UnauthorizedNot authenticated
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn’t exist
409ConflictRequest conflicts with current state
422Unprocessable EntityValidation errors
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server error
502Bad GatewayInvalid upstream response
503Service UnavailableServer temporarily unavailable
504Gateway TimeoutUpstream 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 Location header 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 SeriesStart 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 :)

What's your Feedback?
Do let me know your thoughts around this article.

.NET + AI: Build Smarter, Ship Faster

Join 8,000+ developers learning to leverage AI for faster .NET development, smarter architectures, and real-world productivity gains.

AI + .NET tips
Productivity hacks
100% free
No spam, unsubscribe anytime