Skip to main content

Troubleshooting: message undelivered or failed

Your message reached a terminal failure state — the Delivery Log shows undelivered, failed, rejected, bounced, or expired — and you need to know why, and what to change before re-sending. This page walks you through reading the failure fields, mapping the common causes to their fix, and knowing when a retry is useless. If the row never left the queue (queued, scheduled, pending), work the queue-side troubleshooting page instead. If the row is stuck at sent or submitted_no_receipt with no terminal outcome at all, see Troubleshooting: message sent but no delivery receipt.

The symptom map: four terminal failures are not the same thing

All four failure statuses are terminal (is_terminal: true, state_class: "terminal"), but they point at different layers of the delivery chain. Read the status first — it tells you whose side failed. Three places read the same state:
  • The Delivery Log status filter — /messages/delivery-log?status=undelivered (or failed, rejected, bounced, expired). Overviews are in the delivery log guide.
  • GET /api/v1/messages/:id — the authoritative row state, plus metadata.classified_error_code on terminal failures.
  • The message.failed webhook event — all five failure statuses (including expired) fan out on this event; branch on the payload’s status field, not the event type.

The failure fields to decode

Two layers of failure data ride on a terminal row:
  • metadata.classified_error_code — the normalized, machine-readable failure category. One value, same across channels, present on the GET /api/v1/messages/:id response for terminal failures. Branch your integration on this field, not on the display label.
  • error_code / error_message — the carrier’s own wording of the failure. They ride on the message.failed webhook payload (data.error_code, data.error_message) and are optional — the platform omits both keys when the carrier returned no failure reason, so treat them as may-be-absent. Successful transitions (message.sent, message.delivered, message.read) clear any prior failure reason.
Read classified_error_code for programmatic handling; read error_code / error_message when you open a ticket or trace a carrier.
The per-status decoder:

Cause → fix

Match the cause class from the table above to its fix:

What not to do

  • Do not spam POST /api/v1/messages/:id/retry against an unreachable handset or a rejected sender. Retry re-runs the same gate — an unreachable handset, an unregistered sender, a content violation fail deterministically the second time too. Fix the gate first; retry the row or send a new message after. The same rule the queue-side troubleshooting page lays out applies here.
  • Do not count expired as a failure. It is a closed row with an unknown outcome; it can mask a successful delivery. Exclude it from your hard-failure rate and track it separately.

When to escalate

Open a support ticket when one of these holds:
  • A single message shows an unexpected classified_error_code and the cause-to-fix table above does not explain it.
  • A fleet of failures on one destination, route, or carrier that persists after you adjust routing — the delivery log status=undelivered filter plus the deliverability analytics top-N failure-reason breakdown is the aggregate view to quote.
  • You need the provider-side trace: supply the failure fields from this page.
Include all three so support can pull the DLR pipeline trace without a back-and-forth:
  • Your tenant ID (shown in the dashboard under Settings → Organization, and returned by the GET /api/v1/me response as organizationId).
  • One message ID (msg_…) of a representative failed row.
  • The classified_error_code of that row.
The analytics endpoint’s top-N failure-reason breakdown is the fleet-level view support quotes back at you; run it for a narrow window when a chronic pattern is what you are reporting.

See also