WhatsApp

What is the difference between whatsapp.failed and whatsapp.rejected?

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.

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.

CodeWhy Bird stopped it
undeliverableThe recipient carried no phone number, no business-scoped user ID and no group, so nothing could ever be sent to it
recipient_suppressedThe recipient is on the workspace suppression list
price_not_foundNo price is configured for that destination and template
insufficient_balanceThe 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.

CodeAppears onOrigin
insufficient_balancewhatsapp.rejectedBird
price_not_foundwhatsapp.rejectedBird
recipient_suppressedwhatsapp.rejectedBird
undeliverablebothBird on a rejection, WhatsApp on a failure
internal_errorwhatsapp.failedBird, and WhatsApp was never reached
service_window_expiredwhatsapp.failedWhatsApp
rate_limitedwhatsapp.failedWhatsApp
media_rejectedwhatsapp.failedWhatsApp

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 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 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 covers what to do instead.

Bangun di jaringan yang sama.

Kunci API uji coba langsung tersedia untuk Anda. Akses produksi terbuka saat Anda menambahkan metode pembayaran dan memverifikasi pengirim.

Mulai dengan satu channel.
Tambahkan yang lain saat Anda siap.

API key uji coba langsung tersedia untuk Anda. Akses produksi terbuka setelah Anda menambahkan metode pembayaran dan memverifikasi pengirim.

Menggunakan Claude Code, Cursor, atau Codex? Salin prompt pengaturan dan agen Anda akan menginstal Bird CLI dan skill untuk Anda. Pilih milik Anda:

Cursor