Skip to main content
← blog / api
Nov 10, 2026  ·  #api  ·  7 min read

Status codes your API is getting wrong

The HTTP method is half of your API's contract. The status code is the other half. Here are the codes that get misused most, and why it matters more now that AI agents are calling your API.

My post on choosing between POST, PUT, PATCH, and DELETE is still the most-searched page on this site. It modeled an API for restaurant reservations and argued that the HTTP method is part of the contract. The status code you send back is the other half of that contract, and it gets far less attention.

A developer who hits an error will read the response body and figure out what went wrong. Their code won't, and an agent calling your API through a tool often won't either. Both act on the status code. Each code is an instruction to the caller about what to do next.

More of your API's callers are going to be agents. Here are the status codes I see APIs get wrong most often.

Stop sending errors with a 200

This is the one that breaks callers the worst:

HTTP/1.1 200 OK
Content-Type: application/json

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

Guzzle, Axios, and fetch all treat that as a success. So does every retry layer and monitoring tool in the chain. Your error rate dashboard shows zero.

An agent can read the body, but it often doesn't see your raw response. It sees what the tool wrapping your API hands back, and that tool decided success or failure from the status code. So the agent marks the step as done and moves on. The failure shows up three steps later, somewhere else.

Separate client errors from server errors

The split between 4xx and 5xx is how a caller decides whether to retry.

  • 4xx means the caller did something wrong. Sending the same request again will fail the same way. Don't retry.
  • 5xx means the server failed. The same request might work in a few seconds. Retry with backoff.
  • 429 Too Many Requests is the exception on the 4xx side. Retry, but wait. The Retry-After header says how long.

An uncaught exception from bad input that surfaces as a 500 tells the caller the server broke, not the request. Retry logic will send the same request a few more times and fail each time. An agent might do the same, or decide your API is down and give up. Either way, it never learns that fixing its input would have worked.

Return 201 with a Location header on create

A POST that creates something returns 201 Created, not 200 OK. RFC 9110 says the new resource is identified by the Location header, so send it.

POST /reservations HTTP/1.1
Content-Type: application/json

{"time": "2026-11-20T19:00:00-06:00", "party_size": 4}

HTTP/1.1 201 Created
Location: https://example.com/reservations/42
Content-Type: application/json

{"id": 42, "time": "2026-11-20T19:00:00-06:00", "party_size": 4}

The difference matters once a caller retries. Some APIs return 200 when the resource already existed and they handed back the existing one. If create also returns 200, the caller can't tell "I booked a new table" from "that reservation was already there." An agent that timed out on its first attempt needs exactly that answer.

In a Drupal controller, pass the status and header to the response:

public function create(Request $request): JsonResponse {
  $payload = json_decode($request->getContent(), TRUE, 512, JSON_THROW_ON_ERROR);
  // json_decode() returns mixed. Narrow it before reading keys.
  assert(is_array($payload));
  $reservation = $this->reservationStorage->create([
    'time' => $payload['time'],
    'party_size' => $payload['party_size'],
  ]);
  $reservation->save();

  return new JsonResponse(
    ['id' => $reservation->id(), 'time' => $payload['time'], 'party_size' => $payload['party_size']],
    Response::HTTP_CREATED,
    // The caller should never have to build this URL itself.
    ['Location' => $reservation->toUrl()->setAbsolute()->toString()],
  );
}

If the work happens later, in a queue or a batch job, return 202 Accepted instead. It tells the caller the request was taken, not finished.

Return 204 on delete when there's nothing to say

A successful DELETE has nothing to return. Send 204 No Content with an empty body, not 200 OK with {"success": true}.

public function delete(ReservationInterface $reservation): Response {
  $reservation->delete();
  // No body. A 204 with content in it is a protocol error.
  return new Response(NULL, Response::HTTP_NO_CONTENT);
}

DELETE is idempotent: sending it twice leaves the server in the same state as sending it once. The responses don't have to match. So what should a retry get back?

  • 404 Not Found. The reservation is gone, so there's nothing to find.
  • 204 No Content again. The caller asked for the reservation not to exist, and it doesn't.
  • 410 Gone. With a soft delete, like the canceled reservations in the original post, the record still exists. 410 tells the caller the reservation was deleted, not that the URL is wrong.

404 is the one that trips up agents. An agent that timed out and retried can't tell "already deleted" from "wrong URL." Whichever you pick, list it as an expected response in your OpenAPI spec. The tools wrapping your API are often generated from it.

Use 409 for conflicts and 422 for invalid input

Most APIs return 400 Bad Request for anything wrong with a request. Split it into 409 and 422. Each code tells the caller to do something different.

  • 400 Bad Request: the request is malformed. The JSON doesn't parse, or a required header is missing. Fix the request itself.
  • 422 Unprocessable Content: the request parsed fine, but the values are wrong. The time isn't a valid date, or the party size is zero. Fix the values and send again.
  • 409 Conflict: the values are fine, but they clash with the resource's current state. The reservation was already canceled, or someone else changed it first. Fetch the current state, then decide.

An agent that gets a 422 should change its input. An agent that gets a 409 should go read what's there now. If both come back as 400, the agent has to guess from the message text, and it will guess wrong some of the time.

Entity validation violations map cleanly to a 422. Symfony's exception still uses the code's older name, Unprocessable Entity:

$violations = $reservation->validate();
if ($violations->count() > 0) {
  // The values are bad, not the request. 422 tells the caller to fix the input.
  throw new UnprocessableEntityHttpException((string) $violations);
}

Changing a canceled reservation is a different failure, so it gets a different code:

if ($reservation->isCanceled()) {
  // The new time is valid. The reservation it applies to no longer is.
  throw new ConflictHttpException('This reservation was canceled and cannot be changed.');
}

Look at how Drupal core's JSON:API does it

If you want a reference implementation, Drupal core already ships one. The JSON:API module follows every rule above. From EntityResource::createIndividual():

static::validate($parsed_entity);

// Return a 409 Conflict response in accordance with the JSON:API spec.
if ($this->entityExists($parsed_entity)) {
  throw new ConflictHttpException('Conflict: Entity already exists.');
}

$parsed_entity->save();

// ...
$response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data), 201);

if ($resource_type->isLocatable()) {
  $url = $resource_object->toUrl()->setAbsolute()->toString(TRUE);
  $response->headers->set('Location', $url->getGeneratedUrl());
}

Validation runs first and throws a 422. The conflict check comes second and throws a 409. A successful create returns 201 with a Location header. deleteIndividual() ends with return new ResourceResponse(NULL, 204);.

The comment in EntityValidationTrait explains the 422 choice succinctly: it lets clients tell apart syntax errors in a bad request (400) from semantic errors in a well-formed one (422).

The status code is only half the answer

A 422 tells the caller its input was wrong. It doesn't say which field, or why. A developer can open the docs and work it out. An agent works from whatever the response body gives it.

RFC 9457 defines a standard shape for that body, called problem details. It's an application/problem+json document with type, title, status, detail, and instance members, plus any fields your API needs to add. The RFC is short, and I recommend reading it.

Thanks for reading

I write about Drupal, PHP, and the quiet infrastructure behind large sites — caching, config, and testing. New posts land every week or two.