Skip to main content

Channel fallback recipe: build a two-step fallback chain

This recipe walks one fallback chain from setup to verified delivery: pick the surface, build a two-step primary → fallback chain, extend it to three hops when you need a final catch-all, validate the whole path in sandbox with magic numbers, and read the outcome — including the carriers that never participated — from the cascade receipt. Every control below is tenant-owned: you choose the channels, the order, the escalation window, and the cost caps per send. Nothing here flips a platform-wide switch.

1. Where a fallback chain is set

Two send-time surfaces arm a chain, and they compose differently:
  • Send-time smart route — cascade_policy on POST /messages/smart-send (or read-only on POST /messages/route-preview). The smart router picks the primary channel from your capability flags and message shape; the policy shapes only the fallback set, which is resolved and stamped onto the message as metadata.fallback_channels. A terminal delivery failure on the primary then cascades to the next channel in the list. Messaging channels only — voice and fax are filtered out of the stamped chain.
  • Recipients-and-chain bindings — mode: "waterfall" on POST /notify. You pass one address across two or more bindings[] rows; the binding order is the chain order (the DeliveryChain shape). Only the first binding fires. The rest arm as an ordered fallback chain, and the engine advances when the active hop reports a terminal failure inside fallback_window_seconds.
Rule of thumb: use the smart-send policy when you want the router to choose the primary and you only need to pin the fallback order; use waterfall bindings when you want to control the primary yourself. The fallback chains guide compares both with campaign channels[] ladders and Verify profiles.

2. The two-step recipe: primary → fallback

Two hops is the right size for most traffic: one rich or cheap primary, one universal fallback you know will land. Three worked shapes — all tested in sandbox in section 4.

WhatsApp → SMS (transactional)

The most common production chain: rich primary, universal fallback. The primary is router-picked; the fallback order is pinned.
If the WhatsApp primary comes back undelivered or failed on its terminal receipt, the escalation hook fires a new send on sms carrying the same body. If WhatsApp delivers, the chain ends — SMS never fires.

Email → SMS (one recipient, both channels)

For a notify-style send where you hold both an email address and a phone number for one recipient, enter the same recipient across two bindings so the rows merge into one chain. The email hop is cheapest and fires first; SMS is the safety net.
Response — 200, first hop active, one armed fallback:
Keep the window short for time-critical sends — a 300-second window bound the escalation, so a late email receipt cannot trigger the SMS hop hours later.

Voice → SMS (voice-primary flows)

Voice is dropped from stamped fallback chains everywhere — the smart-send resolver filters it out, and the terminal-DLR escalation hook applies the same eligibility. So a chain that starts on voice and falls back to SMS has to put voice at the front of the binding order on POST /notify, where hop 0 fires immediately:
For OTP traffic specifically, prefer a Verify profile over a hand-rolled notify chain: its step-up engine (sms → whatsapp → voice, say) re-delivers the same code per hop with a templates.voice script, handles per-hop timeouts, and fires verification.fallback_exhausted when every hop fails — none of which you would have to build.

3. The three-step cascade: SMS → voice → email

Add a third hop when the first two are both phone-centric and you need an off-phone catch-all. SMS first (universal, immediate), voice second (gets a ringing phone, not just a notification badge), email last (the permanent record that lands even when the handset is unreachable):
Only the SMS hop fires on this call. A terminal failure advances the chain to voice, then to email; GET /notify/:notifyId later shows every attempted hop with its own cost. fallback: true vs a fallback chain. On campaign-style sends you declare a chain per entry with fallback_on triggers (failed, no_delivery, no_engagement) — a per-hop boolean “this hop may fall back” rather than a chain. Use that trigger style when each entry should independently declare the condition under which the ladder advances (the fallback chains guide covers the trigger semantics). Use the notify waterfall shape (or never set a trigger at all) when the chain is a single ordered list you control end to end. If your send carries no fallback declaration at all, a terminal failure on the primary is final.

4. Validate it in sandbox with magic numbers

Sandbox turns the trailing digit of the recipient into a deterministic receipt scenario, so you can prove the chain advances before it touches a real carrier. Use a sandbox API key (dv_test_sk_* prefix) — the same key shape the sandbox quickstart issues.
  1. Fire the primary against a “always fails” number. Trailing digit 3 returns sent → undelivered within about a second:
    Hop 0 targets a failing number (…003undelivered), hop 1 targets a delivering number (…002delivered). Save the notify_id from the response.
  2. Confirm the chain advanced. Your sandbox webhook endpoint receives the hop-0 terminal receipt, then the hop-1 send — exactly the advance sequence a real carrier produces. The full digit-to-scenario table is at Sandbox magic numbers.
  3. Read the receipt. Resolve the envelope and assert delivered_channel names the fallback hop:
    You want hop 0 failed (or undelivered), hop 1 delivered, and delivered_channel: "sms". If hop 1 still shows in_progress, the window has not closed — poll again.
  4. Negative test the guard. Re-send step 1 with a single binding. The API rejects a one-binding waterfall with 422 and nothing arms — the same guard the composer applies per recipient.
When the sandbox receipt shows the advance you expect, the same payload works in production — sandbox only simulates the receipts, it never rewrites your chain.

5. Failure reading: non-participating carriers and the cascade receipt

GET /notify/:notifyId returns every hop the chain attempted, each with its own error_code, error_message, and billed price. That per-hop read is how you find a carrier that never participated:
  • A hop that never went out — rejected before it armed, or skipped. The receipt’s skipped list (at send time) and per-hop failed entries tell you which binding was rejected and why — for example RECIPIENT_OPTED_OUT on a channel the recipient unsubscribed from. The chain advances past it without spending on that hop.
  • dropped_for_cost_cap — channels the chain declared but never attempted because they would have exceeded your max_total_price budget. A non-participating carrier hops here rather than billing a send you capped.
  • truncated: true — the runaway-envelope ceiling hit and the receipt dropped the overflow. The hops you see are the hops that ran.
Resolve the same notify id in the dashboard’s Track a cascade panel (Messages → Multi-channel notify, below the composer) — one hop list, per-hop cost, the escalated-past-primary flag. The panel and the API read the same envelope; use whichever your runbook prefers. Full field-by-field reading is in Track a cascade.

See also