An HTTP status code is a three-digit number in the first line of every HTTP response, and it's the single piece of information a client can trust without parsing anything else. Before a browser, a mobile app, an HttpClient call, or a load balancer looks at a single byte of the response body, it already knows from the status code alone whether the request succeeded, whether it should retry, whether it should redirect, or whether it should give up and show the user an error. That's the entire point of the code: it's a contract between server and client that exists independently of whatever JSON, HTML, or binary payload follows it.
Getting status codes right matters more in API design than almost any other single decision, because so much infrastructure between your code and the caller — reverse proxies, CDNs, retry policies, circuit breakers, monitoring dashboards, browser caches — makes decisions based on the status code and nothing else. Every code falls into one of five classes, identified by its first digit: 1xx is informational and rare in application code, 2xx means the request succeeded, 3xx means the client needs to look somewhere else, 4xx means the client did something the server won't accept, and 5xx means the server itself failed. Everything below follows from that one-digit rule.
The complete HTTP status code reference
Every code below is registered in the IANA HTTP Status Code Registry. Unofficial codes some frameworks or blog posts mention (like 420 or 599) aren't part of any standard and are intentionally excluded — 418 is the one borderline case, and it's marked below since the registry itself lists it as reserved and unused, not as a real, functioning status.
| Code | Name | Description | Common in .NET? |
|---|---|---|---|
| 1xx Informational | |||
| 100 | Continue | Client should continue sending the request body. | |
| 101 | Switching Protocols | Server is switching protocols per the client's Upgrade header (e.g. to WebSocket). | ✓ |
| 102 | Processing | WebDAV: server accepted the request but processing isn't complete yet. | |
| 103 | Early Hints | Lets the client start preloading resources while the server prepares the final response. | |
| 2xx Success | |||
| 200 | OK | Request succeeded; the response body contains the result. | ✓ |
| 201 | Created | Request succeeded and a new resource was created. | ✓ |
| 202 | Accepted | Request accepted for processing, but processing isn't finished. | ✓ |
| 203 | Non-Authoritative Information | Returned metadata is from a local or third-party copy, not the origin server. | |
| 204 | No Content | Request succeeded; there is intentionally no response body. | ✓ |
| 205 | Reset Content | Client should reset the document view that sent the request. | |
| 206 | Partial Content | Server is delivering only part of the resource due to a Range header. | ✓ |
| 207 | Multi-Status | WebDAV: multiple independent status codes for a single response. | |
| 208 | Already Reported | WebDAV: members already enumerated, not repeated in this response. | |
| 226 | IM Used | Response represents instance-manipulations applied to the current instance. | |
| 3xx Redirection | |||
| 300 | Multiple Choices | Requested resource has several possible representations. | |
| 301 | Moved Permanently | Resource has permanently moved to a new URI. | ✓ |
| 302 | Found | Resource temporarily at a different URI. | ✓ |
| 303 | See Other | Redirects to another URI, to be retrieved with GET, typically after a POST. | |
| 304 | Not Modified | Client's cached copy is still valid; no body is sent. | ✓ |
| 305 | Use Proxy | Deprecated: resource must be accessed through the given proxy. | |
| 307 | Temporary Redirect | Like 302, but guarantees the method and body won't change on the redirected request. | ✓ |
| 308 | Permanent Redirect | Like 301, but guarantees the method and body won't change on the redirected request. | ✓ |
| 4xx Client Error | |||
| 400 | Bad Request | Server can't process the request due to a client error (malformed syntax, invalid framing). | ✓ |
| 401 | Unauthorized | Authentication is required and has failed or not been provided. | ✓ |
| 402 | Payment Required | Reserved for future use. | |
| 403 | Forbidden | Server understood the request but refuses to authorize it. | ✓ |
| 404 | Not Found | Server can't find the requested resource. | ✓ |
| 405 | Method Not Allowed | The HTTP method used isn't supported for this resource. | ✓ |
| 406 | Not Acceptable | No representation matches the client's Accept headers. | |
| 407 | Proxy Authentication Required | Client must authenticate with a proxy first. | |
| 408 | Request Timeout | Server timed out waiting for the request. | |
| 409 | Conflict | Request conflicts with the current state of the resource. | ✓ |
| 410 | Gone | Resource is permanently and intentionally gone, with no forwarding address. | ✓ |
| 411 | Length Required | Server requires a Content-Length header that wasn't provided. | |
| 412 | Precondition Failed | A conditional header (If-Match, If-Unmodified-Since) failed. | |
| 413 | Payload Too Large | Request body is larger than the server is willing to process. | ✓ |
| 414 | URI Too Long | The request URI is longer than the server can process. | |
| 415 | Unsupported Media Type | Request body's media type isn't supported by the server or resource. | ✓ |
| 416 | Range Not Satisfiable | The requested Range header can't be fulfilled. | |
| 417 | Expectation Failed | Server can't meet the requirements of the request's Expect header. | |
| 418 | (Unused) | Formerly "I'm a teapot" (RFC 2324, an April Fools' RFC) — reserved by IANA but not a real, functioning status. See footnote. | |
| 421 | Misdirected Request | Request was sent to a server that can't produce a response for it. | |
| 422 | Unprocessable Content | Request is well-formed but semantically invalid (validation failure). | ✓ |
| 423 | Locked | WebDAV: the resource being accessed is locked. | |
| 424 | Failed Dependency | WebDAV: request failed because a prior required request failed. | |
| 425 | Too Early | Server is unwilling to process a request that might be replayed. | |
| 426 | Upgrade Required | Client should switch to a different protocol (e.g. TLS). | |
| 428 | Precondition Required | Server requires the request to be conditional (to prevent lost updates). | |
| 429 | Too Many Requests | Client has sent too many requests in a given time window (rate limiting). | ✓ |
| 431 | Request Header Fields Too Large | Header fields are too large for the server to process. | |
| 451 | Unavailable For Legal Reasons | Resource is unavailable due to a legal demand (e.g. censorship). | |
| 5xx Server Error | |||
| 500 | Internal Server Error | A generic, unhandled error occurred on the server. | ✓ |
| 501 | Not Implemented | Server doesn't support the functionality required for this request. | |
| 502 | Bad Gateway | Server, acting as a gateway, got an invalid response from an upstream server. | ✓ |
| 503 | Service Unavailable | Server is temporarily unable to handle the request (overload or maintenance). | ✓ |
| 504 | Gateway Timeout | Server, acting as a gateway, didn't get a timely response from an upstream server. | ✓ |
| 505 | HTTP Version Not Supported | Server doesn't support the HTTP version used in the request. | |
| 506 | Variant Also Negotiates | Server has an internal configuration error in content negotiation. | |
| 507 | Insufficient Storage | WebDAV: server can't store the representation needed to complete the request. | |
| 508 | Loop Detected | WebDAV: server detected an infinite loop while processing the request. | |
| 510 | Not Extended | Further extensions to the request are required for the server to fulfill it. | |
| 511 | Network Authentication Required | Client needs to authenticate to gain network access (captive portals). | |
Note on 418: it's listed in the IANA registry itself as "(Unused)". It originated as an April Fools' joke in RFC 2324 (1998) and was formally deprecated for real use by RFC 7168 — the number stays reserved so it's never reassigned, but returning it from a real API is a novelty, not a standard.
1xx Informational
A 1xx response tells the client the initial part of its request has been received and it should keep going, or that the server is switching to a different protocol. These are interim responses — the actual final response with the real status code follows afterward. You'll almost never construct one directly in application code; Kestrel and the HTTP stack handle 1xx responses (like 100 Continue for large request bodies, or 103 Early Hints for resource preloading) at a layer below your controller or endpoint.
2xx Success
A 2xx response means the request was received, understood, and accepted — the specific code tells the client exactly what happened to the resource as a result. Use 200 for "here's your data," 201 when a new resource was created, and 204 when the operation succeeded but there's genuinely nothing to send back. ASP.NET Core's TypedResults class covers all three directly: TypedResults.Ok(), TypedResults.Created(), and TypedResults.NoContent(), each returning a strongly-typed result that also documents the response shape in OpenAPI.
3xx Redirection
A 3xx response tells the client the resource it wants is somewhere else, and whether that's permanent or temporary changes how the client (and any cache in between) should behave afterward. Get the permanent-vs-temporary distinction right — a permanent redirect (301/308) gets cached by browsers and search engines, so pointing it at the wrong URL by mistake is much harder to walk back than a temporary one. TypedResults.Redirect(url, permanent: true) gives you 301 for a GET; the preserveMethod parameter is what actually decides between the 30x pair that keeps the original HTTP method (307/308) and the pair that doesn't (301/302).
4xx Client Error
A 4xx response means the server understood the request but has decided it can't or won't fulfill it as sent — the fault, per HTTP semantics, is attributed to the client, not the server. This is the largest and most nuanced class in real API work, because it's where authentication (401), authorization (403), missing resources (404), and validation (400/422) all live, and mixing those up is the most common category of status code mistake developers make (see the mistakes section below). TypedResults and ControllerBase both provide direct helpers for the common ones: BadRequest(), Unauthorized(), Forbid() (Controllers) or a 403 via TypedResults.StatusCode(403) (Minimal APIs — there's no dedicated TypedResults.Forbidden() at the time of writing), NotFound(), Conflict(), and UnprocessableEntity().
5xx Server Error
A 5xx response means the server itself failed to fulfill a request it otherwise recognized as valid — the fault is attributed to the server, not the client, which matters for monitoring and alerting (a spike in 5xx responses means something is broken on your side; a spike in 4xx means clients are calling you incorrectly). Never return a 5xx for something the client did wrong — a validation failure is a 400 or 422, not a 500. ASP.NET Core's default developer exception page and UseExceptionHandler middleware both produce 500 automatically for unhandled exceptions, and TypedResults.Problem() gives you a spec-compliant RFC 9457 ProblemDetails body to go with it.
The status codes you'll actually use every day
The full registry has around 60 codes. Most APIs only ever return a dozen or two of them in practice. Here's the deep-dive on the ones worth actually knowing cold.
200 OK
What it means: The request succeeded and the response body contains the requested representation.
When to use it / common misuse: Use it for any successful GET, or a successful POST/PUT that doesn't create a new resource (that's 201) and isn't empty (that's 204). The most common misuse is returning 200 for an operation that actually failed, with an error message stuffed into the JSON body instead — see the Common Mistakes section for why that breaks more than it seems to.
// ASP.NET Core Minimal API
app.MapGet("/orders/{id}", (int id) =>
TypedResults.Ok(new OrderDto(id, "Shipped")));
// Controller (IActionResult)
public IActionResult GetOrder(int id) => Ok(new OrderDto(id, "Shipped"));
// Reading it back with HttpClient
var response = await httpClient.GetAsync($"/orders/{id}");
if (response.StatusCode == HttpStatusCode.OK)
{
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
}
Real-world scenario: Here's a common way this bites a team building a public API — a client integration checks response.IsSuccessStatusCode (true for any 2xx) and moves straight to deserializing the body, without checking that the status is specifically 200. A downstream change that starts returning 202 Accepted for a now-asynchronous version of the same endpoint breaks every consumer that assumed the body would already contain the finished result.
201 Created
What it means: The request succeeded and a new resource now exists as a result.
When to use it / common misuse: Use it specifically for POST requests that create a new resource — and per HTTP semantics, include a Location header pointing at the new resource's URL. The common misuse is returning 200 instead of 201 for a creation endpoint, which is harmless in practice for most clients but is technically imprecise and loses the automatic Location header that Created gives you for free.
// Minimal API — sets Location header to the new resource's URL automatically
app.MapPost("/orders", (CreateOrderRequest request) =>
{
var order = CreateOrder(request);
return TypedResults.Created($"/orders/{order.Id}", order);
});
// Controller
public IActionResult CreateOrder(CreateOrderRequest request)
{
var order = CreateOrder(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
Real-world scenario: Consider a team building a "create resource" endpoint that returns 200 instead of 201 with no Location header. Nothing breaks immediately — most clients just read the body. Months later, someone wires up a generic API client library that specifically follows the Location header after any 201 to fetch the canonical resource representation; against this endpoint, it silently has nothing to follow.
204 No Content
What it means: The request succeeded and there is intentionally no response body.
When to use it / common misuse: Use it for successful DELETE requests, or any PUT/PATCH/POST where the client doesn't need anything back. The common misuse is returning 200 with an empty JSON object {} or null body instead of a real 204 — this forces every client to handle a "successful but empty" 200 as a special case, when 204 already communicates that unambiguously at the protocol level.
// Minimal API
app.MapDelete("/orders/{id}", (int id) =>
{
DeleteOrder(id);
return TypedResults.NoContent();
});
// Controller
public IActionResult DeleteOrder(int id)
{
DeleteOrder(id);
return NoContent();
}
Real-world scenario: Here's a common way this shows up: a DELETE endpoint returns 200 with a body of null. A client written against System.Text.Json's ReadFromJsonAsync<T>() throws a deserialization exception on the empty response stream, because 200 implies a body should be there to parse. Switching the endpoint to 204 removes the ambiguity — a 204 response is never expected to have a body, so no client code should be trying to parse one.
301 Moved Permanently
What it means: The resource has permanently moved to a new URI, given in the Location header.
When to use it / common misuse: Use it when a URL is genuinely gone forever and you want browsers and search engines to update their records and stop requesting the old URL (301s get cached long-term by browsers and re-indexed by search engines). The common misuse is using 301 for a redirect that might change later — because clients cache it aggressively, reverting a 301 is far slower to propagate than reverting a 302.
// Minimal API
app.MapGet("/old-path", () => TypedResults.Redirect("/new-path", permanent: true));
// HttpClient following it automatically (default HttpClientHandler behavior)
var response = await httpClient.GetAsync("/old-path");
// response.StatusCode is HttpStatusCode.OK if HttpClient auto-followed the redirect,
// or HttpStatusCode.MovedPermanently if AllowAutoRedirect was disabled.
Real-world scenario: A common way this bites a team: a URL structure changes during a migration, and every old route gets a "temporary" 302 redirect to the new one so nothing breaks immediately. A year later the team wants to finally decommission the old routes — but search engines and some HTTP clients never re-checked the redirect's permanence, because 302 never told them to, so the old URLs keep getting hit indefinitely.
302 Found
What it means: The resource is temporarily available at a different URI.
When to use it / common misuse: Use it when a redirect is genuinely temporary — a login flow bouncing back to the originally requested page, for example. The common misuse is using 302 as if it were 301 for a permanent URL change; it works in the moment (browsers still follow it), but you lose the long-term caching and SEO benefit of telling clients the move is permanent.
// Minimal API — permanent: false is the default
app.MapGet("/dashboard", (HttpContext ctx) =>
ctx.User.Identity!.IsAuthenticated
? TypedResults.Ok(GetDashboard())
: Results.Redirect("/login?returnUrl=/dashboard"));
Real-world scenario: Here's a common way this surfaces: a site permanently restructures its URLs but the redirect layer defaults every route to a 302 because that's what the redirect helper's default argument does. Search engines keep indexing the old URLs indefinitely, since 302 explicitly tells them the change might not be permanent — the fix is a one-line change to pass permanent: true, not a structural one.
304 Not Modified
What it means: The client's cached copy of the resource is still valid; no body is sent.
When to use it / common misuse: This is returned automatically by ASP.NET Core's response caching and static file middleware when a client sends a matching If-None-Match or If-Modified-Since header — you rarely construct it by hand. The common mistake is not implementing ETag or Last-Modified support at all for a cacheable resource, which means every request re-transfers the full body even when nothing changed.
// ASP.NET Core's static file middleware and response caching handle this
// automatically based on ETag/Last-Modified headers. For a custom scenario:
app.MapGet("/resource/{id}", (int id, HttpContext ctx) =>
{
var (etag, resource) = GetResourceWithEtag(id);
if (ctx.Request.Headers.IfNoneMatch == etag)
return TypedResults.StatusCode(StatusCodes.Status304NotModified);
ctx.Response.Headers.ETag = etag;
return TypedResults.Ok(resource);
});
Real-world scenario: Consider an API serving a large, rarely-changing reference dataset (a country/currency list, for example) with no caching headers at all. Every client re-fetches the full multi-hundred-KB payload on every page load, when a simple ETag check would let 304 handle almost all of that traffic with an empty body instead.
400 Bad Request
What it means: The server can't process the request because of a client-side error — malformed syntax, invalid request framing, or (very commonly in practice) a JSON body that doesn't even parse.
When to use it / common misuse: Use it for genuinely malformed input — invalid JSON, a missing required field the model binder can't even construct, an invalid query string format. The common misuse is using 400 for every kind of "the input didn't validate," when a well-formed request that fails business rules is more precisely a 422 (see below) — many teams don't bother with that distinction, which is a defensible simplification as long as it's consistent.
// Minimal API — model binding failures return 400 automatically;
// this is the manual case
app.MapPost("/search", (SearchRequest request) =>
{
if (request.Page < 1)
return TypedResults.BadRequest("Page must be 1 or greater.");
return TypedResults.Ok(RunSearch(request));
});
// Controller with [ApiController] — automatic 400 for invalid ModelState
[HttpPost]
public IActionResult Search(SearchRequest request)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
return Ok(RunSearch(request));
}
Real-world scenario: A typical case: a client sends a request body with Content-Type: text/plain instead of application/json. ASP.NET Core's model binding doesn't even attempt to parse it as the expected type and the request fails before the action method runs — this actually surfaces as 415 Unsupported Media Type, not 400, which is a distinct and easy detail to get confused about when debugging.
401 Unauthorized
What it means: The request requires authentication, and the client hasn't provided valid credentials.
When to use it / common misuse: Use it when the server doesn't know who's calling — a missing, expired, or invalid bearer token. The single most common misuse in the entire status code space is returning 401 when you actually mean 403: 401 is "I don't know who you are," 403 is "I know exactly who you are, and the answer is no." If a valid, authenticated token still gets rejected because of a permissions check, that's 403, not 401.
// Minimal API with an auth-required endpoint — ASP.NET Core's authentication
// middleware returns 401 automatically when [Authorize] fails with no valid identity
app.MapGet("/account", () => TypedResults.Ok(GetAccount()))
.RequireAuthorization();
// Manual case
app.MapGet("/secure", (HttpContext ctx) =>
ctx.User.Identity?.IsAuthenticated == true
? TypedResults.Ok(GetSecureData())
: TypedResults.Unauthorized());
Real-world scenario: Here's a common way this gets confused: a permissions check for an admin-only endpoint returns 401 when a regular authenticated user hits it, on the reasoning that they're "not authorized" for that action. The user's token is completely valid — the server knows exactly who they are — so the technically correct response is 403. Returning 401 here can cause client-side auth libraries to treat it as an expired token and trigger a pointless re-login flow.
403 Forbidden
What it means: The server understood exactly who's asking, and is refusing the request anyway.
When to use it / common misuse: Use it when a fully authenticated caller lacks permission for the specific action or resource. The common misuse is the inverse of the 401 mistake above — using 403 for a request with no credentials at all, when 401 (with a WWW-Authenticate header) is what tells the client it should try authenticating rather than giving up.
// Minimal API — role-based authorization returns 403 for an authenticated
// user who lacks the required role
app.MapDelete("/users/{id}", (int id) => DeleteUser(id))
.RequireAuthorization(policy => policy.RequireRole("Admin"));
// Controller
[Authorize(Roles = "Admin")]
public IActionResult DeleteUser(int id) { DeleteUser(id); return NoContent(); }
// Manual check
public IActionResult DeleteUser(int id)
{
if (!User.IsInRole("Admin")) return Forbid();
DeleteUser(id);
return NoContent();
}
Real-world scenario: A common scenario: a multi-tenant API returns 404 instead of 403 when a fully authenticated user requests a resource that belongs to a different tenant, specifically to avoid confirming the resource exists at all. That's actually a deliberate, defensible security choice in some threat models (403 confirms existence, 404 doesn't) — it's worth being an intentional decision for a specific resource, not an accidental default across the whole API.
404 Not Found
What it means: The server can't find anything matching the requested URI.
When to use it / common misuse: Use it when the requested resource genuinely doesn't exist — an ID that was never valid, or was deleted with no trace kept. The common misuse is using 404 for a resource that used to exist and was deliberately removed; if you want clients to know the removal was intentional and permanent rather than "never existed," 410 Gone is the more precise code (see below).
// Minimal API
app.MapGet("/orders/{id}", (int id) =>
{
var order = FindOrder(id);
return order is not null ? TypedResults.Ok(order) : TypedResults.NotFound();
});
// Controller
public IActionResult GetOrder(int id)
{
var order = FindOrder(id);
return order is not null ? Ok(order) : NotFound();
}
Real-world scenario: Consider an endpoint that returns 404 for both "this ID never existed" and "you don't have permission to see this ID" — a reasonable, common security pattern that avoids leaking which IDs are valid to unauthorized callers. The trade-off is that legitimate callers debugging a real permissions issue see an identical response to a genuine typo in the URL, so good error logging on the server side matters more when this pattern is in use.
405 Method Not Allowed
What it means: The HTTP method used isn't supported for this specific resource, even though the resource itself exists.
When to use it / common misuse: ASP.NET Core's routing returns this automatically when a URL matches a route template but not for the HTTP verb used — you rarely construct it yourself. The common misuse is returning 404 instead when a route exists but the verb doesn't match, which tells the client the URL is wrong rather than the correct, more specific fact that the resource exists but this particular method isn't supported on it.
// A route registered only for GET automatically returns 405 for
// any other verb against the same path — no code needed:
app.MapGet("/orders/{id}", (int id) => TypedResults.Ok(FindOrder(id)));
// A DELETE request to /orders/42 above returns 405, with an
// Allow: GET header, entirely from ASP.NET Core's routing layer.
Real-world scenario: A typical case: an API client library retries a failed request using a different HTTP method as a fallback strategy, and gets back a 405 instead of the 404 it expected for "this endpoint doesn't exist at all." Reading the Allow header that ASP.NET Core includes automatically on a 405 response tells the caller exactly which methods are actually supported, without needing to guess.
409 Conflict
What it means: The request conflicts with the current state of the target resource.
When to use it / common misuse: Use it for genuine state conflicts — creating a resource with a unique key that already exists, or a concurrent-edit conflict caught by an optimistic concurrency token. The common misuse is using 409 for ordinary validation failures that have nothing to do with resource state; that's 400 or 422 territory.
// Minimal API
app.MapPost("/users", (CreateUserRequest request) =>
{
if (UserExists(request.Email))
return TypedResults.Conflict($"A user with email {request.Email} already exists.");
return TypedResults.Created($"/users/{CreateUser(request)}", request);
});
// EF Core optimistic concurrency conflict
try { await dbContext.SaveChangesAsync(); }
catch (DbUpdateConcurrencyException)
{
return TypedResults.Conflict("This record was modified by another request.");
}
Real-world scenario: Here's a common way this surfaces: two requests try to update the same order simultaneously — one to add a discount, one to mark it shipped. Without an optimistic concurrency token (a RowVersion column in EF Core, for example) the second write silently overwrites the first's change instead of failing with a 409, and nobody notices until the discount mysteriously never applied.
410 Gone
What it means: The requested resource is permanently gone, and the server knows it, with no forwarding address.
When to use it / common misuse: Use it specifically when you know for certain a resource existed and was deliberately, permanently removed — a deprecated API version being sunset, or a user-deleted post that shouldn't be findable again. The common misuse is treating 410 as interchangeable with 404; 404 says "nothing here, maybe never was," 410 says "there was definitely something here, and it's gone for good" — search engines and caches treat that distinction as a stronger signal to actually deindex the URL.
// Minimal API — a sunset API version
app.MapGet("/v1/orders/{id}", (int id) =>
TypedResults.StatusCode(StatusCodes.Status410Gone));
// Or with a body explaining the removal
app.MapGet("/v1/orders/{id}", () =>
Results.Json(new { message = "API v1 was retired on 2026-01-01. Use /v2/orders." },
statusCode: StatusCodes.Status410Gone));
Real-world scenario: A common scenario: an API deprecates v1 in favor of v2 and leaves the old routes returning 404, since that was the path of least resistance. Client teams still on v1 see a generic "not found" and have to guess whether it's a bug, a typo, or a deliberate removal — a 410 with a message pointing at the v2 equivalent turns a support ticket into a self-service fix.
422 Unprocessable Content
What it means: The request is syntactically well-formed — valid JSON, correct content type — but fails semantic or business-rule validation.
When to use it / common misuse: Use it when the request parses fine but violates a validation rule: a required field is present but empty, an email field doesn't match a valid email pattern, a date range has an end before its start. RFC 9110 renamed this from "Unprocessable Entity" to "Unprocessable Content" — System.Net.HttpStatusCode exposes both names (UnprocessableContent and UnprocessableEntity) as synonyms for the same 422 value, so either works in code. The common misuse is using 400 for absolutely everything and never reaching for 422 at all — a defensible simplification, but it does throw away a real, useful distinction for API consumers who want to tell "your request was garbled" apart from "your request was clear but invalid."
// Minimal API
app.MapPost("/users", (CreateUserRequest request) =>
{
if (!IsValidEmail(request.Email))
return TypedResults.UnprocessableEntity("Email address is not valid.");
return TypedResults.Created($"/users/{CreateUser(request)}", request);
});
// HttpStatusCode enum — both names are the same underlying value (422)
Console.WriteLine((int)HttpStatusCode.UnprocessableContent); // 422
Console.WriteLine((int)HttpStatusCode.UnprocessableEntity); // 422
Real-world scenario: Consider a signup form endpoint where every validation failure — a malformed request body and an already-registered email address — returns the same 400 with a generic message. A frontend team building error-specific UI (a distinct "check your input format" versus "this email is taken" message) can't tell the two apart from the status code alone and has to parse the error message text instead, which breaks the moment the wording changes.
429 Too Many Requests
What it means: The client has sent too many requests in a given time window and is being rate-limited.
When to use it / common misuse: Use it whenever you're actively rate-limiting a caller, and include a Retry-After header telling them how long to wait — without it, a client has no principled way to know when to try again besides guessing. ASP.NET Core's built-in Microsoft.AspNetCore.RateLimiting middleware returns 429 automatically once a configured limiter rejects a request. The common misuse is rate-limiting without setting Retry-After at all, which pushes well-behaved clients toward blind, uncoordinated retries that make the overload worse, not better.
// Server side — ASP.NET Core rate limiting middleware (built-in, .NET 7+)
builder.Services.AddRateLimiter(options =>
options.AddFixedWindowLimiter("api", opt =>
{
opt.PermitLimit = 100;
opt.Window = TimeSpan.FromMinutes(1);
}));
app.UseRateLimiter();
// Client side — respecting Retry-After on a 429
var response = await httpClient.GetAsync("/orders");
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
await Task.Delay(retryAfter);
response = await httpClient.GetAsync("/orders");
}
Real-world scenario: Here's a common way this bites a team: a third-party API starts returning 429 under load, but the client's HttpClient wrapper only checks for response.IsSuccessStatusCode and treats every non-2xx the same way, immediately retrying in a tight loop. Without reading Retry-After, that retry loop itself becomes the thing keeping the rate limiter triggered.
500 Internal Server Error
What it means: A generic, unhandled error occurred on the server while processing an otherwise valid request.
When to use it / common misuse: This should represent unexpected failures — an unhandled exception, a bug, a downstream dependency behaving in a way the code didn't account for. The common misuse is returning 500 for problems that are actually the client's fault (bad input that validation should have caught) — if a specific, anticipated failure mode keeps producing 500s, that's usually a sign it deserves a proper 4xx instead, with the actual cause identified.
// Program.cs — global exception handling returns 500 with a ProblemDetails body
// for anything unhandled, in one place rather than scattered try/catch blocks
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
title = "An unexpected error occurred.",
status = 500
});
});
});
Real-world scenario: A common way this shows up: a null reference exception deep in a service layer bubbles all the way up and gets caught by a generic exception handler, producing a 500 with no useful detail beyond "an error occurred." Structured logging at the point of the exception (not just at the boundary) is what actually makes a spike in 500s debuggable — the status code alone tells you something broke, not what.
502 Bad Gateway
What it means: A server acting as a gateway or proxy got an invalid response from the upstream server it was trying to reach.
When to use it / common misuse: This is almost always produced by infrastructure — a reverse proxy (nginx, YARP, Azure Front Door) in front of your app, not your application code directly — when the backend process crashes, isn't listening, or returns a malformed response. Application code rarely needs to return this manually; seeing it usually means your ASP.NET Core process itself is down or crashed, not that it returned this status on purpose.
// Handling a 502 from an upstream service you're calling via HttpClient
var response = await httpClient.GetAsync("https://payment-provider.example.com/charge");
if (response.StatusCode == HttpStatusCode.BadGateway)
{
logger.LogWarning("Payment provider's gateway returned 502 — likely a backend crash upstream, not our fault.");
// A 502 from a dependency is often safe to retry once after a short delay.
}
Real-world scenario: A typical scenario: an ASP.NET Core app behind a reverse proxy crashes on startup due to a missing configuration value, and every request to it returns 502 from the proxy — not from the app, since the app never even got far enough to handle the request. Checking application logs for a 502 is often a dead end for exactly this reason; the proxy's own logs are what show the actual failed connection attempt.
503 Service Unavailable
What it means: The server is temporarily unable to handle the request, typically due to overload or maintenance.
When to use it / common misuse: Use it for planned maintenance windows or graceful load-shedding, ideally with a Retry-After header. The common misuse is returning 503 for a permanent failure (that's 500) or forgetting Retry-After, which leaves clients guessing whether to retry in one second or one hour.
// Minimal API — maintenance mode middleware
app.Use(async (context, next) =>
{
if (MaintenanceMode.IsEnabled)
{
context.Response.Headers.RetryAfter = "300";
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new { message = "Undergoing scheduled maintenance." });
return;
}
await next();
});
Real-world scenario: Consider a background job that health-checks a database connection before letting requests through, and returns 500 whenever the check fails during a brief, known maintenance window. A 503 with a Retry-After: 300 header instead tells every well-behaved client and monitoring system exactly when to check back, rather than treating a planned, temporary condition as an unexplained hard failure.
Common status code mistakes
| Mistake | Why it's a problem |
|---|---|
| Returning 200 with an error message in the JSON body | Breaks every HTTP-aware tool between client and server — caching, retries, monitoring, and IsSuccessStatusCode checks all trust the status line, not the body. |
| Confusing 401 and 403 | 401 means "I don't know who you are"; 403 means "I know exactly who you are, and no." Returning the wrong one misleads auth clients into retrying a login that was never the problem. |
| Using 404 when you mean 410 | 404 says "nothing here, maybe never was"; 410 says "this was deliberately, permanently removed." Search engines and caches treat 410 as a much stronger deindexing signal. |
| Returning 500 for client errors | A validation failure or bad input is a 4xx problem, not a 5xx one. Conflating them pollutes server error monitoring with noise that isn't actually a server-side bug. |
| Using 302 when you mean 301 (or vice versa) | 301 gets cached long-term by browsers and re-indexed by search engines; 302 doesn't. Picking the wrong one either makes a temporary redirect impossible to undo, or a permanent one never properly recognized as permanent. |
| Rate limiting with 429 but no Retry-After header | Leaves well-behaved clients with no principled way to know when to try again, which often makes uncoordinated retry storms worse rather than better. |
| Treating every validation failure as 400 | Conflates "the request itself is malformed" (400) with "the request is well-formed but violates a business rule" (422) — a defensible simplification, but a deliberate one, not a default to fall into by accident. |
| Inventing or using unofficial status codes | Codes like 420 or 599 aren't in the IANA registry and aren't guaranteed to be understood correctly by proxies, CDNs, or client libraries — stick to registered codes, even for niche internal APIs. |
.NET quick reference: results to status codes
TypedResults / Results (Minimal API) IActionResult (Controllers) Status
──────────────────────────────────── ──────────────────────────── ──────
TypedResults.Ok() Ok() 200
TypedResults.Created(uri, value) CreatedAtAction(...) 201
TypedResults.NoContent() NoContent() 204
TypedResults.Redirect(url) RedirectToAction(...) 302
TypedResults.Redirect(url, true) RedirectPermanent(...) 301
TypedResults.BadRequest() BadRequest() 400
TypedResults.Unauthorized() Unauthorized() 401
TypedResults.StatusCode(403) Forbid() 403
TypedResults.NotFound() NotFound() 404
TypedResults.Conflict() Conflict() 409
TypedResults.UnprocessableEntity() UnprocessableEntity() 422
TypedResults.StatusCode(n) StatusCode(n) any (n)
TypedResults.Problem(...) Problem(...) 500 (default)
TypedResults.ValidationProblem(...) ValidationProblem(...) 400
There's no dedicated TypedResults.Forbidden() or TypedResults.MethodNotAllowed() helper as of .NET 8 — use TypedResults.StatusCode(403) / StatusCode(405) for those in Minimal APIs. 405 is usually produced automatically by routing rather than returned manually in either style.
Related tools
Working with API responses usually means working with more than just the status code — these tools cover the rest of what typically comes along with it:
- JWT Decoder — decode and inspect the bearer token behind a 401 or 403 response.
- Regex Tester — test the validation patterns that decide whether a request gets a 200 or a 422.
- JSON Formatter — format and inspect the JSON body that comes back alongside any status code.