Both events are terminal and both end a message's timeline, so the natural reading is that one is Bird's fault and the other is WhatsApp's. That is nearly right. The exception is the one worth knowing, because it is the case where you will go looking for a WhatsApp error code that does not exist.

## What does whatsapp.rejected mean?

Bird stopped the message before handing it to WhatsApp, and nothing was [charged](/pricing/whatsapp).

This is the tidy half. A rejection happens at one stage, for one of four reasons, and never leaves the wallet down. Two of the four are checks that deliberately run ahead of the charge, because the charge comes first in the send path and has no refund behind it, so anything that can refuse a message cheaply is asked first. The other two are the charge itself declining. Either way the message is refused without being paid for.

| Code                   | Why Bird stopped it                                                                                                 |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `undeliverable`        | The recipient carried no phone number, no business-scoped user ID and no group, so nothing could ever be sent to it |
| `recipient_suppressed` | The recipient is on the workspace [suppression list](/docs/guides/whatsapp/opt-outs)                                |
| `price_not_found`      | No price is configured for that destination and template                                                            |
| `insufficient_balance` | The wallet could not fund the send, or the organization is not chargeable                                           |

The last two are the charge declining, and refusing there is deliberate: an unpriced product is treated as a misconfiguration rather than as permission to send for free.

One detail matters if you build reporting on these. A rejection is stamped with the time the send was **accepted**, not the time it was processed. The message never progressed past acceptance, so dating it by the moment a queued job happened to run would put it in the wrong bucket whenever the queue was slow.

## What does whatsapp.failed mean?

That the message was not delivered. Who decided that is in the error code, and it is not always WhatsApp.

A failure reaches you from three different places:

1. **WhatsApp reported it.** Its status webhook says `failed`, and the numeric code it sends is mapped onto Bird's normalized code. This is the case everyone expects.
2. **WhatsApp refused the send request.** Bird called Meta's API and got a permanent `4xx` back, so the message was turned away at the API call rather than at delivery. The code is still mapped from Meta's.
3. **The message never reached WhatsApp.** The sending number held no usable credential, or Bird retried the send until it gave up. Both emit `internal_error`.

That third origin is the one to plan for, because it breaks the assumption the other two support. **`internal_error` is the only failure code with no Meta origin at all.** There is no WhatsApp error behind it, so `meta_error_code` is absent by construction, and searching Meta's error reference for an explanation will turn up nothing. The message stopped on Bird's side and the fix is on Bird's side too, usually a disconnected or never-connected sending number.

Note also that a failure can happen after the charge, unlike a rejection. By the time WhatsApp gets the message the wallet has already been debited.

## Which code can appear on which event?

This is the part the API reference does not lay out, and it is what you actually need to branch on.

| Code                     | Appears on          | Origin                                     |
| ------------------------ | ------------------- | ------------------------------------------ |
| `insufficient_balance`   | `whatsapp.rejected` | Bird                                       |
| `price_not_found`        | `whatsapp.rejected` | Bird                                       |
| `recipient_suppressed`   | `whatsapp.rejected` | Bird                                       |
| `undeliverable`          | **both**            | Bird on a rejection, WhatsApp on a failure |
| `internal_error`         | `whatsapp.failed`   | Bird, and WhatsApp was never reached       |
| `service_window_expired` | `whatsapp.failed`   | WhatsApp                                   |
| `rate_limited`           | `whatsapp.failed`   | WhatsApp                                   |
| `media_rejected`         | `whatsapp.failed`   | WhatsApp                                   |

`undeliverable` is the trap. On a rejection it means Bird had nothing to send to. On a failure it means WhatsApp could not reach the recipient, **and** it is the default bucket every Meta code Bird has not explicitly curated falls into. So seeing `undeliverable` on a failure does not mean WhatsApp said "undeliverable"; it means WhatsApp said something that did not map to a more specific code. When you need the finer reason, read `meta_error_code`, which carries WhatsApp's own code, and `description`, which carries WhatsApp's fullest human-readable text for the refusal.

The set is an open enum. Match the codes you handle and accept an unrecognized value rather than treating the response as invalid.

These codes describe a message, and they are a separate vocabulary from the statuses a template's language carries. Nothing here corresponds to a template being rejected or a submission failing, and nothing there corresponds to `undeliverable`. If you are chasing a template that will not send rather than a message that did not arrive, [why a template was rejected](/explained/whatsapp/why-was-my-whatsapp-template-rejected) is the other vocabulary.

## How do I read a message's outcome correctly?

Read the event and the code together, and let the event answer "who" before the code answers "why".

A few things follow from how the timeline is built:

- **The status is the event type with its prefix removed**, so a `whatsapp.failed` event leaves the message `failed` and a `whatsapp.rejected` leaves it `rejected`. The single exception is `whatsapp.read`, which sets no status at all, because reading is engagement rather than a delivery outcome.
- **A rejected message has exactly one event worth acting on.** It stopped at processing, so there is no `sent`, no `delivered` and no WhatsApp code to chase.
- **Not every WhatsApp status becomes an event.** Only `delivered`, `read` and `failed` do. A `sent` status from Meta is dropped, because the send stage already emitted `whatsapp.sent`, and an unrecognized status is dropped as well.
- **A failure with no error detail still carries a code.** If WhatsApp reports a failure without any error attached, it is recorded as `undeliverable` with nothing further, which is another route into that bucket.

For the events themselves, their payloads and the full timeline, [WhatsApp events](/docs/guides/whatsapp/events) is the reference. If the failures you are looking at carry `service_window_expired`, the cause is not really a delivery problem and [the 24-hour customer service window](/explained/whatsapp/what-is-the-24-hour-customer-service-window) covers what to do instead.