VOOZH about

URL: https://codewithmukesh.com/blog/http-status-codes-aspnet-core-api-responses/

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


Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

Back to blog

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.

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

👁 Mukesh Murugan
Mukesh Murugan
Software Engineer
Chapter · 02 of 128 Module 1 of 12 Free
View course

.NET Web API Zero to Hero Course

From dotnet new to docker push — REST, EF Core 10, auth, caching, Clean Architecture, observability. 128 hands-on lessons, source on GitHub.

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 (intid, AppDbContextdb) =>
{
varproduct=awaitdb.Products.FindAsync(id);
returnproductisnotnull
?Results.Ok(product)
:Results.NotFound();
});

Controller:

[HttpGet("{id}")]
publicasyncTask<IActionResult> GetProduct(intid)
{
varproduct=await_db.Products.FindAsync(id);
returnproductisnotnull?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 (CreateProductRequestrequest, AppDbContextdb) =>
{
varproduct=newProduct { Name=request.Name, Price=request.Price };
db.Products.Add(product);
awaitdb.SaveChangesAsync();
returnResults.Created($"/products/{product.Id}", product);
});

Controller:

[HttpPost]
publicasyncTask<IActionResult> CreateProduct(CreateProductRequestrequest)
{
varproduct=newProduct { Name=request.Name, Price=request.Price };
_db.Products.Add(product);
await_db.SaveChangesAsync();
returnCreatedAtAction(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 (intid, AppDbContextdb) =>
{
varproduct=awaitdb.Products.FindAsync(id);
if (productisnull)
returnResults.NotFound();
db.Products.Remove(product);
awaitdb.SaveChangesAsync();
returnResults.NoContent();
});

Controller:

[HttpDelete("{id}")]
publicasyncTask<IActionResult> DeleteProduct(intid)
{
varproduct=await_db.Products.FindAsync(id);
if (productisnull)
returnNotFound();
_db.Products.Remove(product);
await_db.SaveChangesAsync();
returnNoContent();
}

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 (GenerateReportRequestrequest, IMessageQueuequeue) =>
{
varjobId=Guid.NewGuid();
awaitqueue.EnqueueAsync(newGenerateReportJob(jobId, request));
returnResults.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 (CreateOrderRequestrequest, AppDbContextdb) =>
{
if (request.Itemsisnull||request.Items.Count==0)
returnResults.BadRequest(new { Error="Order must contain at least one item" });
// Process order...
returnResults.Created($"/orders/{order.Id}", order);
});

Controller:

[HttpPost]
publicasyncTask<IActionResult> CreateOrder(CreateOrderRequestrequest)
{
if (request.Itemsisnull||request.Items.Count==0)
returnBadRequest(new { Error="Order must contain at least one item" });
// Process order...
returnCreatedAtAction(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")]
publicIActionResultGetProfile()
{
returnOk(_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}")]
publicasyncTask<IActionResult> DeleteUser(intid)
{
if (!User.IsInRole("Admin"))
returnForbid();
// Delete user...
returnNoContent();
}

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 (intid, AppDbContextdb) =>
{
varproduct=awaitdb.Products.FindAsync(id);
returnproductisnotnull
?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 (CreateUserRequestrequest, AppDbContextdb) =>
{
varexistingUser=awaitdb.Users.FirstOrDefaultAsync(u=>u.Email==request.Email);
if (existingUserisnotnull)
returnResults.Conflict(new { Error="A user with this email already exists" });
// Create user...
returnResults.Created($"/users/{user.Id}", user);
});

Controller for optimistic concurrency:

[HttpPut("{id}")]
publicasyncTask<IActionResult> UpdateProduct(intid, UpdateProductRequestrequest)
{
varproduct=await_db.Products.FindAsync(id);
if (productisnull)
returnNotFound();
if (product.Version!=request.Version)
returnConflict(new { Error="The product was modified by another user" });
// Update product...
returnNoContent();
}

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 (CreateProductRequestrequest, AppDbContextdb) =>
{
varerrors=newDictionary<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)
returnResults.UnprocessableEntity(new { Errors=errors });
// Create product...
returnResults.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.


Free resource · Companion download

Interview Questions PDF

100 real interview questions across 9 categories, junior to senior

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 (intid, IReportServicereportService) =>
{
try
{
varreport=awaitreportService.GenerateAsync(id);
returnResults.Ok(report);
}
catch (Exceptionex)
{
// Log the exception
returnResults.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 (intid, IOrderServiceorderService) =>
{
try
{
varorder=awaitorderService.GetOrderAsync(id);
returnResults.Ok(order);
}
catch (InvalidResponseException)
{
returnResults.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 (IHealthCheckServicehealthCheck) =>
{
varisReady=awaithealthCheck.IsReadyAsync();
returnisReady
?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 (stringquery, ISearchServicesearchService, CancellationTokenct) =>
{
try
{
varresults=awaitsearchService.SearchAsync(query, ct);
returnResults.Ok(results);
}
catch (TaskCanceledException)
{
returnResults.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 (intid, AppDbContextdb) =>
{
varproduct=awaitdb.Products.FindAsync(id);
returnproductisnotnull
?Results.Ok(product)
:Results.NotFound();
});
// TypedResults returns concrete types (better for OpenAPI)
app.MapGet("/products/{id}", asyncTask<Results<Ok<Product>, NotFound>> (intid, AppDbContextdb) =>
{
varproduct=awaitdb.Products.FindAsync(id);
returnproductisnotnull
?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", asyncTask<Results<Created<Product>, ValidationProblem, Conflict>>
(CreateProductRequestrequest, AppDbContextdb) =>
{
// Validation failed
if (string.IsNullOrWhiteSpace(request.Name))
returnTypedResults.ValidationProblem(newDictionary<string, string[]>
{
["name"] = ["Name is required"]
});
// Conflict - already exists
if (awaitdb.Products.AnyAsync(p=>p.Sku==request.Sku))
returnTypedResults.Conflict();
// Success
varproduct=newProduct { Name=request.Name, Sku=request.Sku };
db.Products.Add(product);
awaitdb.SaveChangesAsync();
returnTypedResults.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:

Read nextCompanion article

ProblemDetails in ASP.NET Core – Standardizing API Error Responses

Learn how to use ProblemDetails for RFC 7807-compliant error responses.

app.MapGet("/products/{id}", async (intid, AppDbContextdb) =>
{
varproduct=awaitdb.Products.FindAsync(id);
if (productisnull)
{
returnResults.Problem(
title: "Product not found",
detail: $"No product exists with ID {id}",
statusCode: 404,
instance: $"/products/{id}"
);
}
returnResults.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:

returnOk(new { Success=false, Error="User not found" });

Right:

returnNotFound(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.


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 :)

More from the archive.

View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Subscribed · Tue 7 PM IST

You're in.
Welcome to the crew.

Tuesday's issue lands in your inbox at 7 PM IST. One last step: confirm your email so it actually arrives.

01 · Check your inbox

02 · Every Tuesday

Benchmarks, architecture insights, and production tips that never make it to the blog.

Privacy notice · 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →