# HTTP Status Codes and Error Handling

Course: [API](https://www.devtutorials.ai/learn/building-apis) · Section: REST Foundations
Source: https://www.devtutorials.ai/learn/building-apis/rest-foundations/status-codes

> Make every reply tell the truth: choose the right status code, stop hiding errors inside 200 OK, and send errors as Problem Details (RFC 9457).

A **status code** is a three-digit number that the server sends back with every HTTP response. It tells the client whether the request was successful or whether something went wrong.

Status codes give a quick summary of the result before the client reads the response body. They help applications, browsers, and developers understand what happened and decide what to do next.

For example, `200 OK` means the request succeeded, `404 Not Found` means the requested resource does not exist, and `500 Internal Server Error` means something failed on the server. Using the correct status code makes APIs easier to use, troubleshoot, and maintain.

## Watch the App Say "Success!"

Ravi orders a pizza. Brew & Bytes does not sell pizza.

Then Priya orders zero lattes.

Both times, the app shows a green **"Success!"** message.

Both responses return: `200 OK`

The actual error is hidden inside the response body: `{"success": false}`

The app only checks the status code, sees `200`, and assumes everything worked.

**In simple words:** the status code does not match the real result. Clients should not have to search through the response body to discover whether a request failed. The status code should clearly describe the outcome.

## TL;DR

- Status codes are grouped into families.
- **2xx** means the request succeeded.
- **4xx** means the client made a mistake.
- **5xx** means the server failed.
- `200 OK` means success.
- `201 Created` means something new was created.
- `204 No Content` means the request succeeded and there is no response body.
- `400 Bad Request` means the request is invalid.
- `404 Not Found` means the resource does not exist.
- `409 Conflict` means the request conflicts with the current state.
- `422 Unprocessable Entity` means the request is valid but cannot be completed.
- `500 Internal Server Error` means something failed on the server.
- Never return `200 OK` for an error.
- Use **Problem Details** to provide a standard format for error responses.

## A Real-World Example

Think about ordering coffee at a café.

The staff might respond with "Here is your latte." That is a success (`200`). They might say "Your order has been placed. Order number 101." That means something was created (`201`). "We don't sell that item." is a client mistake (`4xx`). "Our coffee machine is broken." is a server problem (`5xx`).

You know the result immediately from the first sentence.

Status codes give applications that same quick understanding.

## What You Will Learn

In this lesson, you will learn:

1. The five status-code families.
2. The most common status codes and when to use them.
3. Why returning `200` for errors causes problems.
4. How to use Problem Details for error responses.
5. How applications can react to different status codes.

## Let the Status Code Speak

The following example returns status codes that match the real result.

| Situation | Status Code |
|---|---|
| Order created successfully | `201 Created` |
| Quantity is 0 | `400 Bad Request` |
| Item does not exist on the menu | `422 Unprocessable Entity` |
| Order exists | `200 OK` |
| Order does not exist | `404 Not Found` |

Now the application can determine the result simply by looking at the status code.

No guessing is required.

### 400 or 422?

Both indicate that the client made a mistake.

Use **400 Bad Request** when the request itself is invalid. Examples are missing required fields, invalid JSON, and invalid values.

Use **422 Unprocessable Entity** when the request is valid but cannot be completed. Examples are ordering an item that does not exist, and requesting an action that violates business rules.

Many APIs use only `400`. The important thing is to follow the same rule consistently throughout the API.

A `4xx` means "the client can fix this": change the request and try again. A `5xx` means "this is not your fault": the client can only wait and retry. Getting the family right tells the client what to do next.

## Problem Details: A Standard Error Format

The status code explains the type of error. The response body should explain the details.

**Problem Details** is a standard JSON structure used for API errors.

Common fields include:

| Field | Purpose |
|---|---|
| `type` | A link describing the error type |
| `title` | A short summary of the error |
| `status` | The HTTP status code |
| `detail` | A detailed explanation of the problem |
| `errors` | Validation errors grouped by field |

Problem Details responses use the content type: `application/problem+json`

This tells clients that the response contains error information.

For validation errors, the response looks like this: `{"errors": {"quantity": ["Quantity must be greater than zero."]}}` The application immediately knows which field caused the problem. For other errors, the API can provide a clear title, a detailed explanation, and the correct status code.

In ASP.NET Core, helpers such as:

```csharp
Results.ValidationProblem(...)
Results.Problem(...)
```

can generate Problem Details automatically.

## 409 Conflict

Sometimes a request is valid, but it cannot be completed because of the current state of the resource. Example: an open order can be cancelled, but a delivered order cannot be cancelled.

An open order returns `200 OK`. A delivered order returns `409 Conflict`. A missing order returns `404 Not Found`. The request itself is valid. The problem is that the current state does not allow the operation.

**Never return `200` for an error.** Clients, browsers, caches and monitoring tools all trust the status code. A `200` with `"success": false` inside looks like success to every one of them. Dashboards stay green while customers fail.

## The Client's Side

Clients should react differently depending on the status code.

Typical behavior:

| Status Code | Client Response |
|---|---|
| `200` | Show the data |
| `201` | Show success and the new resource |
| `400` | Ask the user to fix the input |
| `404` | Show "Not Found" |
| `409` | Explain the conflict |
| `5xx` | Show "Please try again later" |

Most applications handle all server errors (`5xx`) in a similar way because users cannot fix them.

Do not put internal details in an error, like stack traces, SQL or file paths. They help attackers and confuse customers. Write a clear `title` and `detail`, and log the technical details on the server.

## Where You See This

- Mobile apps show different screens for authentication errors, missing data, and server failures.
- Monitoring tools use `5xx` responses to detect outages.
- Browsers and API clients rely on status codes before reading response bodies.
- ASP.NET Core and many other frameworks support Problem Details automatically.

## Common mistakes

| Symptom | Cause | Fix |
|---|---|---|
| The app shows "Success!" for a failed order | The API returns `200` with the error in the body | Return the right `4xx` |
| The app cannot find a new order | `201` is sent without a `Location` header | Add `Location: /orders/{id}` |
| Monitoring shows no errors during an outage | Server failures are sent as `200` | Let real failures be `5xx` |
| Users see stack traces | The text of the exception goes into the response | Send Problem Details with a plain `title`, and log the rest |

## Exercises

## What to Remember

- The status code tells the client what happened, before it reads the body.
- **2xx** worked, **4xx** is a client mistake, **5xx** is a server mistake.
- Use `201` + `Location` for something new, `404` for missing, `409` for a clash, and `400`/`422` for bad input.
- Send errors as **Problem Details**, `application/problem+json`.
- Never return `200` for an error.

**Next:** Request Validation and DTOs. You will stop bad data before it reaches the database.

var app = builder.Build();

var menu = new Dictionary<string, int> { ["Latte"] = 220, ["Masala Chai"] = 120 };

// Every answer is 200 OK. The real result is hidden inside the body.
app.MapPost("/orders", (NewOrder input) =>
{
    if (!menu.ContainsKey(input.Item))
        return TypedResults.Ok(new Reply(false, "We do not sell " + input.Item));
    if (input.Quantity < 1)
        return TypedResults.Ok(new Reply(false, "Quantity must be at least 1"));
    return TypedResults.Ok(new Reply(true, "Order placed"));
});

app.Run();

record NewOrder(string Item, int Quantity);
record Reply(bool Success, string Msg);`;

type NewOrder = { item: string; quantity: number };
const menu = new Map<string, number>([["Latte", 220], ["Masala Chai", 120]]);

const app = new Hono();

// Every answer is 200 OK. The real result is hidden inside the body.
app.post("/orders", async (c) => {
  const input = await c.req.json<NewOrder>();
  if (!menu.has(input.item)) return c.json({ success: false, msg: "We do not sell " + input.item }, 200);
  if (input.quantity < 1) return c.json({ success: false, msg: "Quantity must be at least 1" }, 200);
  return c.json({ success: true, msg: "Order placed" }, 200);
});

Content-Type: application/json

{"item":"Pizza","quantity":1}

###
POST /orders
Content-Type: application/json

{"item":"Latte","quantity":0}`;

var app = builder.Build();

var menu = new Dictionary<string, int> { ["Latte"] = 220, ["Masala Chai"] = 120 };
var orders = new List<Order>();

// Now the status code itself says what happened.
app.MapPost("/orders", (NewOrder input) =>
{
    if (input.Quantity < 1) return Results.BadRequest();            // 400: the request is wrong
    if (!menu.ContainsKey(input.Item)) return Results.UnprocessableEntity(); // 422: well formed, but we cannot do it
    var order = new Order(orders.Count + 101, input.Item, input.Quantity);
    orders.Add(order);
    return Results.Created("/orders/" + order.Id, order);          // 201: made something new
});

app.MapGet("/orders/{id:int}", (int id) =>
    orders.FirstOrDefault(o => o.Id == id) is { } order
        ? Results.Ok(order)                                           // 200: here it is
        : Results.NotFound());                                        // 404: no such order

app.Run();

record NewOrder(string Item, int Quantity);
record Order(int Id, string Item, int Quantity);`;

type NewOrder = { item: string; quantity: number };
type Order = { id: number; item: string; quantity: number };

const menu = new Map<string, number>([["Latte", 220], ["Masala Chai", 120]]);
const orders: Order[] = [];
const app = new Hono();

// Now the status code itself says what happened.
app.post("/orders", async (c) => {
  const input = await c.req.json<NewOrder>();
  if (input.quantity < 1) return c.body(null, 400); // 400: the request is wrong
  if (!menu.has(input.item)) return c.body(null, 422); // 422: well formed, but we cannot do it
  const order: Order = { id: orders.length + 101, item: input.item, quantity: input.quantity };
  orders.push(order);
  c.header("Location", "/orders/" + order.id);
  return c.json(order, 201); // 201: made something new
});

app.get("/orders/:id", (c) => {
  const order = orders.find((o) => o.id === Number(c.req.param("id")));
  return order ? c.json(order, 200) : c.body(null, 404); // 200 here it is, 404 no such order
});

Content-Type: application/json

{"item":"Latte","quantity":2}

###
POST /orders
Content-Type: application/json

{"item":"Latte","quantity":0}

###
POST /orders
Content-Type: application/json

{"item":"Pizza","quantity":1}

###
GET /orders/101

###
GET /orders/555`;

var app = builder.Build();

var menu = new Dictionary<string, int> { ["Latte"] = 220, ["Masala Chai"] = 120 };

// Problem Details (RFC 9457): one standard shape for every error.
app.MapPost("/orders", (NewOrder input) =>
{
    if (input.Quantity < 1)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["quantity"] = new[] { "Quantity must be at least 1." }
        });
    }
    if (!menu.ContainsKey(input.Item))
    {
        return Results.Problem(
            statusCode: 422,
            title: "Item not on the menu",
            detail: "We do not sell " + input.Item + ".");
    }
    return Results.Created("/orders/101", new { id = 101, input.Item, input.Quantity });
});

app.Run();

record NewOrder(string Item, int Quantity);`;

type NewOrder = { item: string; quantity: number };
const menu = new Map<string, number>([["Latte", 220], ["Masala Chai", 120]]);

// Problem Details (RFC 9457): one standard shape for every error.
// "type" is a link that explains the kind of error.
const TYPES: Record<number, string> = {
  400: "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  404: "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  409: "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  422: "https://tools.ietf.org/html/rfc4918#section-11.2",
};

function problem(c: Context, status: number, title: string, extra: Record<string, unknown> = {}) {
  const body = { type: TYPES[status], title, status, ...extra };
  return c.body(JSON.stringify(body), status, { "Content-Type": "application/problem+json" });
}

const app = new Hono();

app.post("/orders", async (c) => {
  const input = await c.req.json<NewOrder>();
  if (input.quantity < 1) {
    return problem(c, 400, "One or more validation errors occurred.", {
      errors: { quantity: ["Quantity must be at least 1."] },
    });
  }
  if (!menu.has(input.item)) {
    return problem(c, 422, "Item not on the menu", { detail: "We do not sell " + input.item + "." });
  }
  c.header("Location", "/orders/101");
  return c.json({ id: 101, item: input.item, quantity: input.quantity }, 201);
});

Content-Type: application/json

{"item":"Latte","quantity":2}

###
POST /orders
Content-Type: application/json

{"item":"Latte","quantity":0}

###
POST /orders
Content-Type: application/json

{"item":"Pizza","quantity":1}`;

var app = builder.Build();

var orders = new Dictionary<int, Order>
{
    [101] = new(101, "Latte", "delivered"),
    [102] = new(102, "Masala Chai", "open")
};

// 409 Conflict: the request is fine, but it clashes with the order's current state.
app.MapPost("/orders/{id:int}/cancel", (int id) =>
{
    if (!orders.TryGetValue(id, out var order))
        return Results.Problem(statusCode: 404, title: "Order not found");
    if (order.Status == "delivered")
        return Results.Problem(statusCode: 409, title: "Order already delivered",
            detail: "Order " + id + " was delivered, so it cannot be cancelled.");
    orders[id] = order with { Status = "cancelled" };
    return Results.Ok(orders[id]);
});

app.Run();

record Order(int Id, string Item, string Status);`;

type Order = { id: number; item: string; status: string };
const orders = new Map<number, Order>([
  [101, { id: 101, item: "Latte", status: "delivered" }],
  [102, { id: 102, item: "Masala Chai", status: "open" }],
]);

const TYPES: Record<number, string> = {
  404: "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  409: "https://tools.ietf.org/html/rfc9110#section-15.5.10",
};

function problem(c: Context, status: number, title: string, extra: Record<string, unknown> = {}) {
  const body = { type: TYPES[status], title, status, ...extra };
  return c.body(JSON.stringify(body), status, { "Content-Type": "application/problem+json" });
}

const app = new Hono();

// 409 Conflict: the request is fine, but it clashes with the order's current state.
app.post("/orders/:id/cancel", (c) => {
  const id = Number(c.req.param("id"));
  const order = orders.get(id);
  if (!order) return problem(c, 404, "Order not found");
  if (order.status === "delivered") {
    return problem(c, 409, "Order already delivered", {
      detail: "Order " + id + " was delivered, so it cannot be cancelled.",
    });
  }
  const cancelled = { ...order, status: "cancelled" };
  orders.set(id, cancelled);
  return c.json(cancelled, 200);
});

###
POST /orders/101/cancel

###
POST /orders/999/cancel`;

static string MessageFor(int status) => status switch
{
    200 or 201 => "Done!",
    400 => "Please check what you typed.",
    401 => "Please sign in.",
    404 => "We could not find that.",
    409 => "That cannot be done right now.",
    422 => "We could not do that.",
    >= 500 => "Something went wrong on our side. Try again soon.",
    _ => "Unexpected reply: " + status
};

foreach (int status in new[] { 201, 400, 404, 409, 503 })
{
    Console.WriteLine(status + " -> " + MessageFor(status));
}`;

function messageFor(status: number): string {
  if (status === 200 || status === 201) return "Done!";
  if (status === 400) return "Please check what you typed.";
  if (status === 401) return "Please sign in.";
  if (status === 404) return "We could not find that.";
  if (status === 409) return "That cannot be done right now.";
  if (status === 422) return "We could not do that.";
  if (status >= 500) return "Something went wrong on our side. Try again soon.";
  return "Unexpected reply: " + status;
}

for (const status of [201, 400, 404, 409, 503]) {
  console.log(status + " -> " + messageFor(status));
}`;

  {
    prompt: "Return the right status code for each of the six situations.",
    hint: "Saved: 201. Found: 200. Missing: 404. Broken JSON: 400. Clashes with the current state: 409. Server failure: 500.",
    explain: "The family says whose problem it is: 2xx is fine, 4xx is the client, and 5xx is the server.",
    csharp: { starter: `// Return the right status code for each situation.
static int StatusFor(string situation) => situation switch
{
    _ => 200
};

Console.WriteLine("New order saved: " + StatusFor("new order saved"));
Console.WriteLine("Order found: " + StatusFor("order found"));
Console.WriteLine("No order with that id: " + StatusFor("no order with that id"));
Console.WriteLine("Body is not valid JSON: " + StatusFor("body is not valid JSON"));
Console.WriteLine("Cancel a delivered order: " + StatusFor("cancel a delivered order"));
Console.WriteLine("The database crashed: " + StatusFor("the database crashed"));`, solution: `// Return the right status code for each situation.
static int StatusFor(string situation) => situation switch
{
    "new order saved" => 201,
    "order found" => 200,
    "no order with that id" => 404,
    "body is not valid JSON" => 400,
    "cancel a delivered order" => 409,
    "the database crashed" => 500,
    _ => 200
};

Console.WriteLine("New order saved: " + StatusFor("new order saved"));
Console.WriteLine("Order found: " + StatusFor("order found"));
Console.WriteLine("No order with that id: " + StatusFor("no order with that id"));
Console.WriteLine("Body is not valid JSON: " + StatusFor("body is not valid JSON"));
Console.WriteLine("Cancel a delivered order: " + StatusFor("cancel a delivered order"));
Console.WriteLine("The database crashed: " + StatusFor("the database crashed"));`, mode: "console" },
    react: { starter: `// Return the right status code for each situation.
function statusFor(situation: string): number {
  return 200;
}

console.log("New order saved: " + statusFor("new order saved"));
console.log("Order found: " + statusFor("order found"));
console.log("No order with that id: " + statusFor("no order with that id"));
console.log("Body is not valid JSON: " + statusFor("body is not valid JSON"));
console.log("Cancel a delivered order: " + statusFor("cancel a delivered order"));
console.log("The database crashed: " + statusFor("the database crashed"));`, solution: `// Return the right status code for each situation.
function statusFor(situation: string): number {
  const codes: Record<string, number> = {
    "new order saved": 201,
    "order found": 200,
    "no order with that id": 404,
    "body is not valid JSON": 400,
    "cancel a delivered order": 409,
    "the database crashed": 500,
  };
  return codes[situation] ?? 200;
}

console.log("New order saved: " + statusFor("new order saved"));
console.log("Order found: " + statusFor("order found"));
console.log("No order with that id: " + statusFor("no order with that id"));
console.log("Body is not valid JSON: " + statusFor("body is not valid JSON"));
console.log("Cancel a delivered order: " + statusFor("cancel a delivered order"));
console.log("The database crashed: " + statusFor("the database crashed"));`, mode: "console" }
  },
  {
    prompt: "The app waits for 201 Created and a Location header after a new order. Fix the reply.",
    hint: "In C#, use Results.Created(\"/orders/\" + order.Id, order). In Hono, set the Location header and return status 201.",
    explain: "201 says that something new was made. Location tells the client where to find it.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var orders = new List<Order>();

// The app waits for 201 Created and a Location header. Fix the reply.
app.MapPost("/orders", (NewOrder input) =>
{
    var order = new Order(orders.Count + 101, input.Item, input.Quantity);
    orders.Add(order);
    return Results.Ok(order);
});

app.Run();

record NewOrder(string Item, int Quantity);
record Order(int Id, string Item, int Quantity);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var orders = new List<Order>();

// The app waits for 201 Created and a Location header. Fix the reply.
app.MapPost("/orders", (NewOrder input) =>
{
    var order = new Order(orders.Count + 101, input.Item, input.Quantity);
    orders.Add(order);
    return Results.Created("/orders/" + order.Id, order);
});

app.Run();

record NewOrder(string Item, int Quantity);
record Order(int Id, string Item, int Quantity);`, mode: "aspnet", requests: `POST /orders
Content-Type: application/json

{"item":"Latte","quantity":2}` },
    react: { starter: `import { Hono } from "hono";

type NewOrder = { item: string; quantity: number };
type Order = { id: number; item: string; quantity: number };

const orders: Order[] = [];
const app = new Hono();

// The app waits for 201 Created and a Location header. Fix the reply.
app.post("/orders", async (c) => {
  const input = await c.req.json<NewOrder>();
  const order: Order = { id: orders.length + 101, item: input.item, quantity: input.quantity };
  orders.push(order);
  return c.json(order, 200);
});

type NewOrder = { item: string; quantity: number };
type Order = { id: number; item: string; quantity: number };

const orders: Order[] = [];
const app = new Hono();

// The app waits for 201 Created and a Location header. Fix the reply.
app.post("/orders", async (c) => {
  const input = await c.req.json<NewOrder>();
  const order: Order = { id: orders.length + 101, item: input.item, quantity: input.quantity };
  orders.push(order);
  c.header("Location", "/orders/" + order.id);
  return c.json(order, 201);
});

Content-Type: application/json

{"item":"Latte","quantity":2}` },
    checks: `POST /orders
Content-Type: application/json

{"item":"Masala Chai","quantity":3}

###
POST /orders
Content-Type: application/json

{"item":"Cold Coffee","quantity":1}`
  },
  {
    prompt: "A quantity below 1 must be rejected with a 400 validation problem, with errors.quantity set to \"Quantity must be at least 1.\"",
    hint: "In C#, return Results.ValidationProblem(...) with a dictionary of errors. In Hono, use the problem() helper with status 400.",
    explain: "A validation problem tells the app exactly which field to fix, in the standard Problem Details shape.",
    csharp: { starter: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// A quantity below 1 must be rejected with a 400 validation problem:
// errors: { "quantity": ["Quantity must be at least 1."] }
app.MapPost("/orders", (NewOrder input) =>
{
    return Results.Created("/orders/101", new { id = 101, input.Item, input.Quantity });
});

app.Run();

record NewOrder(string Item, int Quantity);`, solution: `var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// A quantity below 1 must be rejected with a 400 validation problem:
// errors: { "quantity": ["Quantity must be at least 1."] }
app.MapPost("/orders", (NewOrder input) =>
{
    if (input.Quantity < 1)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["quantity"] = new[] { "Quantity must be at least 1." }
        });
    }
    return Results.Created("/orders/101", new { id = 101, input.Item, input.Quantity });
});

app.Run();

record NewOrder(string Item, int Quantity);`, mode: "aspnet", requests: `POST /orders
Content-Type: application/json

{"item":"Latte","quantity":0}` },
    react: { starter: `import { Hono } from "hono";

type NewOrder = { item: string; quantity: number };

function problem(c: Context, status: number, title: string, extra: Record<string, unknown> = {}) {
  const body = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.1", title, status, ...extra };
  return c.body(JSON.stringify(body), status, { "Content-Type": "application/problem+json" });
}

const app = new Hono();

// A quantity below 1 must be rejected with a 400 validation problem:
// title "One or more validation errors occurred."
// errors: { "quantity": ["Quantity must be at least 1."] }
app.post("/orders", async (c) => {
  const input = await c.req.json<NewOrder>();
  c.header("Location", "/orders/101");
  return c.json({ id: 101, item: input.item, quantity: input.quantity }, 201);
});

type NewOrder = { item: string; quantity: number };

function problem(c: Context, status: number, title: string, extra: Record<string, unknown> = {}) {
  const body = { type: "https://tools.ietf.org/html/rfc9110#section-15.5.1", title, status, ...extra };
  return c.body(JSON.stringify(body), status, { "Content-Type": "application/problem+json" });
}

const app = new Hono();

// A quantity below 1 must be rejected with a 400 validation problem:
// title "One or more validation errors occurred."
// errors: { "quantity": ["Quantity must be at least 1."] }
app.post("/orders", async (c) => {
  const input = await c.req.json<NewOrder>();
  if (input.quantity < 1) {
    return problem(c, 400, "One or more validation errors occurred.", {
      errors: { quantity: ["Quantity must be at least 1."] },
    });
  }
  c.header("Location", "/orders/101");
  return c.json({ id: 101, item: input.item, quantity: input.quantity }, 201);
});

Content-Type: application/json

{"item":"Latte","quantity":0}` },
    checks: `POST /orders
Content-Type: application/json

{"item":"Masala Chai","quantity":-2}

###
POST /orders
Content-Type: application/json

{"item":"Cold Coffee","quantity":3}`
  }
];
