Skip to main content

Should HTTP Status Codes Reflect Business Outcomes?

I’ve delivered my Beyond the Code: Services Which Stand the Test of Time presentation a handful of times this year. Toward the end of the first half, I take a stronger stance and talk more directly about the design of RESTful interfaces.

Most of the material in the presentation is framed as guidelines rather than hard rules. However, the RESTful section leans much more strongly toward rules, and there’s one rule in particular that I consider non-negotiable:

Never, EVER, use a field in a response to indicate success!

Then I show this example on the next slide of why you shouldn’t do it:

Just in case it isn’t immediately obvious, there is a conflict here between the HTTP status code of 200, indicating everything is OK, and the success field in the response body saying that it isn’t. Usually this gets a knowing, sometimes uncomfortable laugh from the audience, but not tonight in Digital Colchester.

One audience member challenged this stance with a question I’ve been thinking about since I first wrote my RESTful Behaviour Guide: am I coupling the HTTP request with the business capability the service delivers?

HTTP Requests and Business Capabilities

To explore that question, it helps to separate two concerns that are often conflated.

An HTTP request and a business capability represent two distinct concerns. An HTTP request covers the technical interaction with a service: whether the request reached the service, was understood, and whether the handler executed without error. By contrast, a business capability reflects the outcome of the domain-specific operation the request was intended to perform, such as processing a payment, placing an order, or validating a user’s eligibility.

From this perspective, a request can succeed, for example, it arrives successfully and is handled by the service without error, while the underlying business action fails for entirely different reasons. A credit card might be declined, an order might already have been shipped, or a user might not meet certain criteria.

The argument, then, is that HTTP status codes should report only on the success or failure of the request itself, while the response body should communicate the business outcome. Combining both concerns into a single signal risks losing important information. It’s a reasonable position, but it relies on one key assumption: that HTTP semantics actually support such a clear separation.

The answer depends on what you think a 2xx status code actually means.

What the Specification Says

The answer isn’t subjective, it’s defined in the HTTP specification.

RFC 9110, the current HTTP semantics specification, defines the 2xx range as (15.3):

The request was successfully received, understood, and accepted.

The word accepted is what’s important here. HTTP is an application protocol, not a transport protocol. A 2xx doesn’t just mean that the request arrived, it means that the service agreed to do what you asked. This shouldn’t be confused with a 202 Accepted response, which means the service is going to process the request asynchronously. If the business rule rejected the request, the server didn't accept it. It understood it, but declined to fulfil it. That's 4xx according to the specification.

In practice, most of these cases are already well covered by existing 4xx status codes:

  • 422 Unprocessable Content - Syntactically valid, semantically understood, but the business rules say no. The canonical "your request was fine, the answer is no."
  • 409 Conflict - State-based rejection. "Order already shipped." "Email already taken."
  • 403 Forbidden - The caller isn't allowed to do this.
  • 402 Payment Required - Increasingly used for billing and quota outcomes. Stripe returns this for a card decline.

Instead of inventing your own error envelope, RFC 9457 - Problem Details for HTTP APIs,  provides a standard body shape for failures, consisting of type, title, detail, and status fields.

Adding an additional indicator of success to the response body adds no value.

Business Capability Response

That said, not every negative outcome is an error.

For query-style endpoints, a negative result doesn’t automatically mean something went wrong. A call to GET /eligibility/{user} may get back {eligible: false}, and that’s still a perfectly valid, successful response. The question was “is this user eligible?” and the system answered it. There’s no error in the request or the processing. The outcome just happens to be “no.” In cases like that, a 200 fits, because the API did exactly what it was asked to do; the business outcome is the resource.

The same applies to decision-as-a-service style endpoints. A fraud check returning {decision: "decline", reason: "..."} hasn’t failed, it has done exactly what it was designed to do. Declining is a normal, expected outcome of that service, not an exceptional condition. From the API’s perspective, it evaluated the request and produced a result, which is about as successful as it gets.

The Exceptions

There are, however, some genuine exceptions, cases where returning a 2xx alongside outcome details in the response body does make sense.

If you set pure REST concerns aside, GraphQL is the obvious example. By design, it almost always returns HTTP 200 unless there’s a transport-level failure, and pushes errors down into the response body instead. That’s because a GraphQL query can be partially successful, so a single HTTP status code can’t reliably capture the full outcome.

Of course a 207 Multi-Status response is the REST equivalent.

Finally

The more I’ve reflected on that audience question, the clearer the distinction has become. HTTP status codes are not just transport signals; they are part of the application contract. A 2xx response means the request was accepted in a meaningful sense, not just received without error. When a system refuses to carry out what was asked, whether due to validation, state, permissions, or business rules, that refusal should be expressed through the status code, not hidden in the response body.

At the same time, not every negative outcome is a failure. When an endpoint exists to answer a question or produce a decision, responses like false or decline are valid results, not errors. The key is to distinguish between a result of the operation and a rejection of the request. Keep status codes focused on the request, keep the body focused on the outcome, and avoid duplicating intent across both. That’s why the rule still stands: you don’t need a success field, and adding one only introduces ambiguity where HTTP already provides clarity.


Comments

  1. Borrow a standard's semantics for an orthogonal model and the seams always show. The lingua franca buys you shared tooling and familiarity, but you pay at the edges. HTTP retries your "idempotent" PUT and ships the order twice. No hard rule, however simple, maps perfectly to every case. Document and be consistent.

    ReplyDelete

Post a Comment

Popular posts from this blog

It's great to be back at the ACCU Conference 2025!

Last week I was back at the ACCU Conference in Bristol, for the first time since I gave an opening keynote in 2019 . In March 2020, COVID hit the UK and I got out of the habit of attending and speaking at conferences. Plus the ACCU Conference, and the organisation in general, took a big step back towards C++ and that hasn’t been relevant to me for nearly two decades. It’s taken until now, 2025 for me to want to talk again, and I was really pleased to be accepted for the ACCU Conference again. It was just a 20 minute session and I only attended for the day I was speaking. There was enough varied content for it to be interesting to me and I’m hoping to be back for the full conference next year. I really enjoyed attending and it was great to catch up with people I hadn’t seen for years. I felt re-engaged and particularly liked being asked if I’d been to nor(DEV):con . Learning to stop writing code (and why you won't miss it) Daisy Hollman I should have read the summary of the openin...

Write Your Own Load Balancer: A worked Example

I was out walking with a techie friend of mine I’d not seen for a while and he asked me if I’d written anything recently. I hadn’t, other than an article on data sharing a few months before and I realised I was missing it. Well, not the writing itself, but the end result. In the last few weeks, another friend of mine, John Cricket , has been setting weekly code challenges via linkedin and his new website, https://codingchallenges.fyi/ . They were all quite interesting, but one in particular on writing load balancers appealed, so I thought I’d kill two birds with one stone and write up a worked example. You’ll find my worked example below. The challenge itself is italics and voice is that of John Crickets. The Coding Challenge https://codingchallenges.fyi/challenges/challenge-load-balancer/ Write Your Own Load Balancer This challenge is to build your own application layer load balancer. A load balancer sits in front of a group of servers and routes client requests across all of the serv...

Catalina-Ant for Tomcat 7

I recently upgraded from Tomcat 6 to Tomcat 7 and all of my Ant deployment scripts stopped working. I eventually worked out why and made the necessary changes, but there doesn’t seem to be a complete description of how to use Catalina-Ant for Tomcat 7 on the web so I thought I'd write one. To start with, make sure Tomcat manager is configured for use by Catalina-Ant. Make sure that manager-script is included in the roles for one of the users in TOMCAT_HOME/conf/tomcat-users.xml . For example: <tomcat-users> <user name="admin" password="s3cr£t" roles="manager-gui, manager-script "/> </tomcat-users> Catalina-Ant for Tomcat 6 was encapsulated within a single JAR file. Catalina-Ant for Tomcat 7 requires four JAR files. One from TOMCAT_HOME/bin : tomcat-juli.jar and three from TOMCAT_HOME/lib: catalina-ant.jar tomcat-coyote.jar tomcat-util.jar There are at least three ways of making the JARs available to Ant: Copy the JARs into th...