HTTP 200 Is Not Delivered: What a Success Response Actually Proves

The API returned HTTP 200 and a message id. The message never arrived. Both of those facts are consistent with a system working exactly as designed.
I want to be specific about my own error here, because it is the useful part. I read the 200 as evidence that the send had been permitted — that the messaging window was open, since otherwise surely the request would have failed. That inference was wrong. The response could not have told me that, and I stated it as though it had.
What a synchronous 200 can and cannot cover
A success response on a send endpoint means accepted for delivery. That covers everything the platform can determine while your connection is still open:
- Your credentials are valid and scoped correctly.
- The payload parses and passes schema validation.
- The recipient identifier is well formed.
- You are inside your rate limit.
It cannot cover anything decided after the handoff. Policy evaluation, recipient state, carrier acceptance, template status, spam filtering, delivery itself. All of that happens asynchronously, and none of it can reach back into a response that has already been sent.
The message id you get back is a handle for tracking that work. It is not a receipt.
A 200 tells you the request was well formed. Delivery is a separate event, reported later, through a different channel, and it is the only one your business cares about.
The specific rule that caused it
Business messaging platforms restrict what a business can send to someone who has not recently written to them. On WhatsApp's Cloud API, free-form messages initiated by a business are only permitted inside a 24-hour service window that opens when the customer sends a message.
Outside that window, a free-form send is accepted with a 200 and then fails. The rejection arrives asynchronously, as a status update on a webhook, some seconds or minutes later.
Approved message templates are the exception. They do not require the window, which is the entire reason the template system exists.
Then the failure reason vanished
The delivery status was being posted to a webhook running as a serverless function with no persistent storage.
It received the failure. It had nowhere to put it. By the time anyone went looking, the invocation was gone and there was nothing to read back.
Which left the only authoritative record inside the platform's own message logs in its admin console — reachable by a human in a browser, not by the system that needed it. So the operational answer to "did that message get delivered" was: log in and look, one message at a time.
An asynchronous result you do not durably store is a result you did not receive.
The cheap decisive test
When a send is accepted and nothing arrives, there are several candidate explanations: wrong number, account not provisioned, policy block, window closed, recipient issue. Working through them one at a time is slow and mostly guesswork.
There is a single test that splits the space in one move: send an approved template to the same recipient.
Templates carry fewer preconditions — no window requirement. So:
- The template arrives. The number is right, the account is provisioned, the connection works. Your free-form send failed on the window or on policy. Everything upstream is fine.
- The template does not arrive either. The problem is not the window. It is the number, the account, or the credentials, and now you are looking in the right place.
One send, thirty seconds, and half the hypotheses are eliminated. The general principle: to isolate a failing precondition, retry with the variant that has the fewest preconditions.
Everywhere else 200 means queued
This is not a messaging quirk. It is the default architecture of every send-and-forget API you use.
- Transactional email. Accepted by the provider, then bounced, blocked, or filtered. Accepted and delivered are different fields for a reason.
- Ad platform writes. A created campaign is not an approved campaign, and an approved campaign is not a delivering one.
- Payments. Authorised is not captured, and captured is not settled. Each can fail after the one before it succeeded.
- Your own webhooks. Returning 200 from a webhook handler tells the sender you received the payload. It says nothing about whether you processed it.
- Bulk audience uploads. Accepted immediately, matched and populated over the following hour, and the size that proves it worked is not readable until then.
The protocol
- Define done as observable at the destination. The message in the inbox, the row in the table, the record in the CRM. Never the response code on the way there.
- Persist every asynchronous callback before doing anything else with it. Write to durable storage first, process second. A stateless handler that only logs has thrown away the only copy.
- Never quote a status code as evidence about a policy or a precondition. Codes describe the request. Policies are evaluated elsewhere.
- Keep one manual verification path. The vendor's own console is slow and it is authoritative. Know where it is before you need it at speed.
- Separate accepted from delivered on every dashboard. One number for both is the reporting equivalent of the mistake this article is about.
What this is really about
Every one of these failures shares a shape: an early, cheap, encouraging signal standing in for a late, expensive, real one.
The signal is not lying. A 200 is true. It is answering a narrower question than the one you asked, and the gap between those two questions is where the entire failure lives.
The operator's habit worth building is small and it holds everywhere: when you catch yourself saying "it worked", finish the sentence. It worked as far as what? Then go and look at the next thing along.