Skip to content
All posts

Your API Returns 200 OK for Every Error

May 18, 2026·Read on Medium·

There is an RFC for this. Your framework already supports it. You just never looked.

I was debugging a payment integration last year when the upstream provider returned this:

{
"status": "error",
"code": 1042,
"message": "Insufficient balance"
}

HTTP status: 200 OK.

My retry middleware saw a successful response. My monitoring dashboard recorded zero failures. My alerting rules never fired. The payment silently failed for 14 hours before a customer complained.

The upstream API had decided that HTTP status codes were suggestions. Their error lived inside a 200 response body, wrapped in a proprietary JSON format that no HTTP tool on earth was designed to parse automatically.

This is not a rare pattern. This is most APIs.

The 200 OK Lie

There is a specific contract between an HTTP server and a client. Status codes exist to communicate what happened before the client even reads the body. A 200 means the request succeeded. A 400 means the client sent something wrong. A 500 means the server broke. This contract predates REST, predates JSON and predates most developers writing APIs today.

The entire HTTP ecosystem is built on this contract. Proxies cache 200 responses. CDNs store them. Browsers treat them as safe. Load balancers use status codes for health decisions. Monitoring tools aggregate them into error rate percentages. Circuit breakers count 5xx responses to decide when to open. Every layer of your infrastructure reads the status code before it reads a single byte of the body.

Yet a surprising number of production APIs ignore it entirely. They return 200 for everything and stuff the real outcome into a custom envelope:

{
"success": false,
"error_code": "VALIDATION_FAILED",
"errors": [
{ "field": "email", "message": "Invalid format" }
]
}

HTTP status: 200 OK.

Every HTTP client, every proxy, every CDN, every load balancer, every monitoring tool treats this as a successful request. Your Nginx access logs count it as a 2xx. Your error rate dashboards stay flat. Your retry logic never retries. Your circuit breaker never trips.

You have not saved yourself complexity by doing this. You have hidden your failure rate from every piece of infrastructure between your server and the person calling it.

The usual defense is that HTTP status codes are too limited to express application-level errors. This is true. 404 does not tell you whether the user was not found or the endpoint does not exist. 400 does not tell you which field failed validation.

But the answer to “status codes are not enough” was never “ignore them entirely.” The answer was published as an RFC three years ago.

RFC 9457 Exists and You Have Never Used It

In July 2023, the IETF published RFC 9457, titled “Problem Details for HTTP APIs.” It was authored by Mark Nottingham, Erik Wilde and Sanjay Dalal. It obsoletes the earlier RFC 7807, which defined the same concept but with less precise guidance.

The idea is straightforward. When your API returns an error, the response body follows a standard JSON structure with five well-defined fields:

{
"type": "https://api.example.com/errors/insufficient-balance",
"title": "Insufficient Balance",
"status": 422,
"detail": "Account balance is $12.50, but the transaction requires $45.00.",
"instance": "/transactions/abc-123"
}

The response carries Content-Type: application/problem+json. The HTTP status code is a proper 4xx or 5xx. The body gives the client both a machine-readable error type (a URI) and a human-readable explanation.

The type field is a URI that identifies the category of error. Every occurrence of the same problem shares the same type. The detail field is specific to this occurrence. The instance field points to the specific resource or request that triggered it.

You can extend it. Need to include validation errors? Add an errors array. Need a trace ID? Add a traceId field. The spec explicitly allows custom fields as long as the five standard ones are present.

This is not academic. This is a published internet standard with framework support across every major backend ecosystem.

Your Framework Already Supports This

Here is the part that makes this inexcusable.

Spring Framework added built-in RFC 9457 support in version 6.0.0. The ProblemDetail class is a first-class citizen. You extend ResponseEntityExceptionHandler, declare it as @ControllerAdvice, and every exception your app throws automatically returns a Problem Details response with application/problem+json as the content type. Spring even registers a Jackson mixin to serialize custom properties as top-level JSON fields.

// Spring 6+ returns RFC 9457 responses out of the box
@ControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(InsufficientBalanceException.class)
ProblemDetail handleInsufficientBalance(InsufficientBalanceException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY,
ex.getMessage()
);
problem.setType(URI.create("https://api.example.com/errors/insufficient-balance"));
problem.setTitle("Insufficient Balance");
return problem;
}
}

ASP.NET Core has ProblemDetails baked into the framework. Calling AddProblemDetails() on your service collection enables it globally. The ExceptionHandlerMiddleware and StatusCodePagesMiddleware automatically convert unhandled errors and empty error responses into Problem Details format. It is enabled by default in current versions.

FastAPI does not include it natively, but the fastapi-problem-details package (currently at version 0.1.4 on PyPI) wraps your exception handlers to produce compliant responses with minimal configuration.

Laravel has no built-in support. Community packages like laravel-api-problem and the PHP library crell/api-problem provide RFC 9457 formatting, but you have to wire them into your exception handler manually. For a framework that ships opinions on everything from authentication to broadcasting, the absence of a standard error format is a notable gap.

The tooling exists. The standard exists. The excuse does not.

Your Custom Error Format Is Technical Debt

Every team that invents their own error envelope pays the same tax over time.

The first version is simple. A success boolean, an error_code string, a message. Then someone adds a data field for partial responses. Then the mobile team asks for an errors array with field-level validation messages. Then the partner integration team discovers that your error codes changed between v2 and v3 of the API, because nobody documented the contract.

Now you have three clients parsing three slightly different error shapes from the same API. Your SDK maintainer is writing conditional logic to detect which format they are dealing with. Your frontend team has a 200-line utility function called parseApiError that handles nine different response structures, three of which are technically success responses that contain errors.

This is not hypothetical. The Postman 2025 State of the API Report surveyed over 5,700 developers and found that poor or missing documentation remains the top pain point, with over half of teams spending significant effort dealing with documentation gaps. Error response formats are the dark corner of that problem. Nobody documents them because nobody agrees on what they should look like. RFC 9457 eliminates that argument. The format is defined. The fields are specified. The content type is registered. Your clients know what to parse before they ever call your API.

AI Agents Cannot Parse Your Custom Errors

There is a newer reason this matters, and it is not theoretical.

The same Postman report found that AI agents are transforming how APIs are consumed. Agents call APIs programmatically, interpret responses and chain actions based on what they receive. When your API returns a 200 with a custom error body, a human developer can read the message and figure out what went wrong. An AI agent cannot.

Consider what an agent sees when it calls your API and gets back a 200 with {"success": false, "error_code": "E_1042"}. The HTTP layer says success. The body says failure, but in a format the agent has never encountered before. The error code is meaningless without documentation the agent cannot read. The agent has no way to decide whether to retry, abort or try a different parameter. It either treats every 200 as success and plows forward with bad data, or it needs hand-coded parsing logic for every single API it integrates with.

Now consider what the same agent sees with RFC 9457. The HTTP status is 422. The agent already knows this is a client error. The type URI identifies the error category. The detail field explains what specifically went wrong. The agent can make an informed decision: retry with different parameters, surface the error to the user or fall back to an alternative path.

Agents need predictable schemas, typed errors and clear behavioral rules to function as intended. A URI-based type field is parseable. A machine-readable status code is actionable. A detail string with a specific error description is useful context for a retry decision. A proprietary error_code: 1042 with no documentation is a dead end.

If your API will be consumed by anything other than a human reading JSON in a browser dev tools panel, your error format is now part of your machine interface. RFC 9457 was designed for exactly this.

The Migration Is Not Hard

You do not need to rewrite your API to adopt Problem Details. The migration path is incremental.

Start with new endpoints. Every new route returns application/problem+json for errors. Set the correct HTTP status code. Include type, title, status and detail at minimum.

For existing endpoints, introduce a response header. When the client sends Accept: application/problem+json, return the standard format. When they do not, return your existing format. This gives consumers time to migrate without breaking existing integrations.

Version your error types. The type URI does not need to resolve to a webpage, but it should be stable. https://api.yourapp.com/errors/validation-failed is a contract. Do not change it without a deprecation plan.

Add custom fields where they matter. Validation errors? Include an errors array. Rate limiting? Add a retryAfter field. Distributed tracing? Add traceId. The spec explicitly supports extension fields.

{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 422,
"detail": "2 fields failed validation.",
"errors": [
{ "field": "email", "reason": "Must be a valid email address." },
{ "field": "amount", "reason": "Must be greater than zero." }
],
"traceId": "req-8f3a-4b2c-9d1e"
}









This response tells the client what went wrong, which fields to fix, what type of problem it is and how to trace it through your logs. No guessing. No parsing heuristics. No 200 OK pretending everything is fine.

The Cost of Doing Nothing

The argument against adopting a standard error format is usually inertia. “Our clients already parse the existing format.” “It works fine.” “Nobody has complained.”

Nobody complained about the 200 OK errors either. Not until the retry logic stopped retrying. Not until the monitoring stopped monitoring. Not until the AI agent could not figure out why the third step in a five-step workflow kept silently succeeding while actually failing.

The cost of a nonstandard error format is invisible until it compounds. Every new client integration spends time reverse-engineering your error shapes. Every monitoring tool miscounts your real error rate. Every automated consumer treats your failures as successes.

RFC 9457 is three years old. Your framework probably supports it already. The only thing missing is the decision to use it.

Stop returning 200 OK for failures. Your infrastructure already knows what to do with a proper status code. Let it.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
Your API Returns 200 OK for Every Error — Hafiq Iqmal — Hafiq Iqmal