Choosing the Right HTTP Status Codes for Your API

Choosing the Right HTTP Status Codes for Your API

7 min read··Updated ·

api-designaspnetcorerest

Choose the status code class from the outcome: 2xx means success, 4xx means the caller must change something, 5xx means the server must fix something. The ambiguous cases each resolve with one question: 400 vs 422 is "schema or rules?", 401 vs 403 is "who are you vs you may not", 404 vs 410 is "unknown vs deliberately gone". And never return 200 with an error in the body.

Nobody has ever argued about 200 OK or 500 Internal Server Error. The design review fights live in the ambiguous middle: is invalid input 400 or 422? Is a missing permission 401 or 403? And someone always suggests returning 200 with "success": false, which is how monitoring goes blind.

Status codes are not decoration. Caches key on them, retry policies branch on them, dashboards alert on them. Here is the decision tree for every ambiguous case I keep re-litigating, with the ASP.NET Core mappings.

If you have a specific API outcome in front of you, use the interactive HTTP Status Code Wizard to get a recommendation, Problem Details response, and ASP.NET Core examples.

Why Do Status Codes Matter?

Before the tree, the stakes. A status code is a machine-readable statement of a request's outcome, and three audiences consume it without reading your docs:

  • Infrastructure: CDNs cache 200s and 404s, never 500s. Proxies and gateways count 5xx for health. Get the class wrong and caching or failover misbehaves.
  • Client retry logic: 429 and 503 are retryable, 400 and 422 are not. A client that cannot tell them apart either retries forever or gives up on transient failures.
  • Your own observability: alerts fire on 5xx rates. Errors hidden inside 200s never page anyone.

The class is the contract: 2xx success, 4xx the caller must change something, 5xx we must fix something. Every decision below is just sharpening within a class.

400 vs 422: Could Not Read It vs Could Not Accept It

The most common fight, and the line is clean:

  • 400 Bad Request: the request is malformed. Broken JSON, a string where a number belongs, missing structural pieces. The message never made it to your domain logic.
  • 422 Unprocessable Entity: the request parsed perfectly, and your rules rejected the content. End date before start date. Quantity of -5. Registration with a taken username... wait, no, that one is 409 (next section).

A practical test: could a schema validator have caught it? Then 400. Did it need your business rules? Then 422.

ASP.NET Core nudges you here already: model binding and JSON deserialization failures produce 400 automatically, and validation frameworks report rule violations, which you map to 422:

app.MapPost("/api/bookings", async (
    CreateBookingRequest request,
    IValidator<CreateBookingRequest> validator) =>
{
    // Malformed JSON never reaches this line - the framework already sent 400

    var validation = await validator.ValidateAsync(request);

    if (!validation.IsValid)
    {
        return Results.ValidationProblem(
            validation.ToDictionary(),
            statusCode: StatusCodes.Status422UnprocessableEntity);
    }

    // ...
});

Both should carry a Problem Details body naming the offending fields. The status tells clients whether to fix the request; the body tells them what to fix. Wire that up once with FluentValidation and an exception handler, and the decision is made in one place forever.

401 vs 403: Who Are You vs You Specifically, No

Misnamed by history (401 Unauthorized is actually about authentication), so ignore the names and use the semantics:

  • 401: I do not know who you are. Token missing, expired, or invalid. Crucially, 401 is an invitation: authenticate properly and try again (the WWW-Authenticate header is literally part of the contract).
  • 403: I know exactly who you are, and you may not do this. Valid token, insufficient permissions. Retrying with the same identity will never help.

The security nuance: 403 confirms the resource exists and admits the caller's identity was understood. For resources whose existence is itself sensitive (other tenants' data), many APIs deliberately return 404 instead of 403, and that is a legitimate, documented choice. I went deeper on this pair in 401 vs 403 in ASP.NET Core, including how the authentication and authorization middleware pick between them for you.

404 vs 410: Not Here vs Gone Forever

  • 404 Not Found: nothing at this address (or nothing I will admit to). Safe default, no promises about the past or future.
  • 410 Gone: this existed, it was removed on purpose, and it is not coming back. Deleted user accounts, expired one-time links, sunset API versions.

410 is the polite one: it tells consumers to delete their references and stop retrying, and search crawlers to de-index now instead of revisiting. Use it when you can actually verify the past existence (soft-deleted rows make this trivial); use 404 when you cannot or will not.

For sunsetting old API versions, 410 with a body pointing at the migration guide is exactly the right send-off.

409: The State Says No, For Now

409 Conflict covers requests that are well-formed and pass validation, but collide with current state:

  • Creating something that already exists (duplicate username, duplicate SKU).
  • Optimistic concurrency failures: the resource changed since you read it (412 if you are using If-Match preconditions, 409 for application-detected conflicts).
  • State machine violations: cancelling an order that already shipped.

The distinction from 422: a 422 request can never succeed as written; a 409 request might succeed at another time or after a re-read. That difference is exactly what a client's conflict-resolution logic keys on.

The 2xx Family Beyond 200

Underused, and each one carries real information:

  • 201 Created: resource creation succeeded; include a Location header pointing at the new resource. Results.Created() does both.
  • 202 Accepted: work accepted, not done. The backbone of async APIs for long-running requests: return 202 plus a status URL, not a 30-second-held 200.
  • 204 No Content: success with nothing to say. The natural response for DELETE and for PUT/PATCH when you do not echo the resource.

The 200-With-Error-Body Lie

Every few months someone proposes the envelope:

HTTP/1.1 200 OK

{ "success": false, "error": "Order not found" }

The arguments for it ("clients only need one parsing path", "some proxies eat 4xx bodies") were weak in 2015 and are dead now. What it actually does:

  • Monitoring goes blind. Error rate dashboards read 0% while customers see failures.
  • Caches store failures. A CDN happily caches that 200 "not found" and serves it after the order exists.
  • Retry logic breaks. Nothing distinguishes transient from permanent, so clients retry nothing or everything.
  • Every client must learn your envelope, re-implementing what HTTP already standardized.

A status code that contradicts the body is a lie to every intermediary between you and the caller. If the operation failed, the status is 4xx or 5xx, full stop. (GraphQL's 200-always model is the one principled exception, and it ships an entire error specification to compensate.)

The Cheat Sheet

The whole flow, from an incoming request down to a status code, is one walk through a series of questions:

Decision tree that walks a request through readability, authentication, authorization, business rules, and state conflict checks, mapping each failed check to 400, 401, 403, 422, or 409 and success to 2xx

The decision tree, compressed:

  • Request unreadable or structurally invalid: 400
  • No valid credentials: 401
  • Valid credentials, insufficient rights: 403 (or 404 to hide existence)
  • No such resource: 404
  • Existed, deliberately removed forever: 410
  • Readable but violates business rules as written: 422
  • Valid but collides with current state: 409 (412 with preconditions)
  • Over the rate limit: 429, with the headers from communicating 429s properly
  • Created: 201 + Location. Accepted for later: 202 + status URL. Done, nothing to return: 204
  • We broke: 500. Dependency broke: 502. We are overloaded or down on purpose: 503 + Retry-After

Summary

Let the outcome choose the status. Status codes are the part of your API contract that machines act on without asking. The ambiguous cases stop being ambiguous with the right question: 400 vs 422 is "schema or rules?", 401 vs 403 is "who are you vs you may not", 404 vs 410 is "unknown vs deliberately gone", 422 vs 409 is "never valid vs not valid right now".

Encode the decisions once (validation filter, exception handler, Problem Details everywhere) so individual endpoints cannot freelance. And never ship the 200-with-error-body lie: the day your dashboard shows 0% errors during an outage is the day you will understand why status codes were the contract all along.

Frequently Asked Questions

What is the difference between 400 and 422?

Use 400 when the request is malformed and could not be understood: broken JSON, wrong types, missing required structure. Use 422 when the request was perfectly readable but fails your business rules: an end date before a start date, an amount over the limit.

When should I use 401 vs 403?

401 means "I do not know who you are": credentials missing, expired, or invalid, and retrying with valid credentials could succeed. 403 means "I know exactly who you are, and the answer is no": authentication succeeded but the caller lacks permission.

Is it OK to return 200 with an error in the body?

No. Status codes are the contract that caches, proxies, monitoring, and client retry logic all key on. A 200 carrying an error body makes failures invisible to every layer of infrastructure and forces every client to parse your custom envelope to learn what HTTP already says.

When should an API return 404 vs 410?

Return 404 when the resource does not exist or you will not confirm it exists. Return 410 Gone when the resource verifiably existed and was permanently removed, which tells clients and crawlers to stop trying and delete their references.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.