
Table of Contents
- The Short Answer
- TL;DR
- What the Platform Fallback Flow Does
- Where the Platform Flow Stops
- The Gated Decision: Platform, Orchestrator or Both
- Decision Matrix by Message Type
- Which Failures Should Move to the Next Channel
- Failures That Must Never Trigger Fallback
- Consent Gates Between Steps
- Deadlines: Falling Back on Silence
- The Same OTP on Every Channel
- Adding Voice OTP as the Last Step
- The Attempt Ledger
- A Fallback Orchestrator in Python
- Reading Outcomes Per Channel
- Cost: Every Hop Is a Billed Send
- Measuring Fallback: Hop Rate and Time to Deliver
- Rollout Checklist
- Common Mistakes
- Unspecified Behaviour and How to Code Around It
- FAQs
The Short Answer
Use the platform’s Multi-Channel Fallback flow when your ladder only uses SMS, WhatsApp and RCS, and “move on when this step fails” is all the logic you need. Write your own orchestrator when you need a voice OTP or Telegram step, a deadline for a channel that’s gone quiet, a consent check before each hop, or per-attempt records in your own database. Plenty of teams run both: platform flows for simple alerts, their own code for OTP.
Whichever you pick, three rules don’t change. Never fall back on a failure that means the person said no (a blocked number, a blacklist entry, a WhatsApp marketing opt-out). Send the same OTP on every step, so a late delivery is harmless. And give exactly one system the job of retrying, never two.
TL;DR
- The platform flow tries SMS, WhatsApp and RCS in the order you set, one approved template per channel, with variables copied from the first channel. Leave trigger codes empty and any failure moves to the next step. Pick codes and only those do.
- It stops at three channels. Voice, Telegram and email aren’t part of it. It also moves on when a step fails, so you need your own answer for a step that never reports at all.
- The decision is a gate, not a preference. Need voice, Telegram, silence deadlines, consent checks per hop, or your own audit trail? Then you need an orchestrator. If not, configure a flow and keep your code simple.
- Some failures must never fall back:
BLOCK_NUMBER,BLACK_LISTED,CONSENT_FAILEDand WhatsApp error 131050. Each one is an answer from the recipient or the regulator, and switching channels ignores it. - Check consent before every hop. Permission to text someone isn’t permission to message them on WhatsApp.
- Fall back on silence, not just failure. A
PENDINGthat never turns into anything is the most common way an OTP goes missing. Give each step a deadline. - Send one OTP value down the whole ladder. If SMS delivers after WhatsApp already has, the user sees the same code twice, and nothing breaks.
- Reserve each step in a ledger before you call the provider. A unique key on (message, step) means a crash or retry can’t send the same step twice.
- Every hop is a billed send. SMS is charged at submission, failed or not, so the ladder costs the first step plus every fallback that runs.
- Measure hop rate and time to deliver per step. Those two numbers tell you whether the ladder order is right.
What the Platform Fallback Flow Does
Start with what you get for free, because it’s a lot. The platform feature is configured in the user panel under Fallback, then Template Mapping. A flow is a named ladder: you tick the channels, drag them into order, pick a template for each, and save. Every send that uses the flow follows the same path.
Here are the rules it runs by, all taken from the Multi-Channel Fallback knowledge base:
| Rule | What it means for your design |
|---|---|
| Channels are SMS, WhatsApp Business API and RCS | Voice, email and Telegram can’t be steps in a platform flow |
| At least two eligible channels on your account | With one channel, the Create button stays hidden |
| Each channel appears once per flow | No “SMS, WhatsApp, SMS again” ladders |
| Top step is tried first, bottom step is the last attempt | Order is the main lever you have |
| One approved template per channel | DLT template plus sender ID for SMS, a Meta-approved template for WhatsApp, an RCS template for RCS |
| The first channel is the source for variable mapping | Every placeholder on a later step is filled from a first-step variable or from static text |
| Trigger codes per step, except the last | Empty means any failure moves on. Selected codes mean only those do |
| Status switch pauses a flow, Delete removes it | Disable during template changes, recreate only when you mean to |
| Delivery Reports show each attempt | Search by date, number, channel or error code, and see whether failover ran |
| Failover Analytics by date or flow name | Total sent, delivered, failover-assisted, success by step position |
Two of them catch people out.
Empty trigger codes. The trigger error codes guide recommends leaving them empty for OTP and critical alerts, so any delivery failure moves to the next channel. That’s the right default for coverage. It also means every failure moves on, including the handful you’d rather stop on. More on that in Failures That Must Never Trigger Fallback.
Variable mapping from the first step. On the platform, the first channel’s template is where values come from. The OTP mapping guide shows the pattern: SMS template “Your OTP is {#var#}. Valid 5 min.”, WhatsApp template “Your verification code is {{1}}.”, and {{1}} mapped to the SMS variable. Every step shows the same code. Keep that property if you write your own orchestrator, too. It’s what makes late deliveries harmless.
The product page sums up the send side in one line: “Use the flow name in your API or panel sends.” Keep that exact name string in your config, and treat it like any other identifier your code depends on.
Where the Platform Flow Stops
The flow is a ladder of channel attempts driven by delivery failures. Anything outside that shape is your job. In practice, five things push teams to write code.
A voice OTP step. For many Indian users, a voice call reading out the code is the most reliable last resort when SMS and WhatsApp both fail. Voice isn’t a platform fallback channel. If you want it on the ladder, your code has to place the call through the Voice Call API.
A Telegram step. Same story. Telegram isn’t part of the feature, so a ladder that includes it is your ladder.
Silence. The flow moves on when a step fails. A step that sits in PENDING and never reports is a different problem. An OTP that hasn’t arrived after 30 seconds is already a lost login for a lot of users, whether or not a failure ever shows up. Your code can act on “no answer by time T”. See Deadlines: Falling Back on Silence.
Consent per hop. The platform tries the next channel because it’s next. It can’t know that this user agreed to SMS alerts but never opted in to WhatsApp. Your consent tables can. See Consent Gates Between Steps.
Your own record of each attempt. Delivery Reports and Failover Analytics live in the panel. If your support team, your audit trail or your billing reconciliation needs every hop as a row in your database, keyed to your own message ID, you’ll be building that ledger anyway. Once you’ve built it, the orchestrator is a small step further.
None of this makes the platform flow worse. It just has a clear shape, and some messages don’t fit it.
The Gated Decision: Platform, Orchestrator or Both
Treat this as a set of gates rather than a taste question. Run each message type through them, in order. The first “yes” decides.
- Does the ladder include voice or Telegram? Yes means your own orchestrator.
- Does a silent step need to move on before it fails? Yes means your own orchestrator. OTP almost always lands here.
- Does any step need a consent check the platform can’t see? Typical case: SMS is covered by a transactional relationship and WhatsApp needs a separate opt-in. Yes means your own orchestrator, or a platform flow used only for recipients you’ve already confirmed on every channel in it.
- Do you need each hop as a row in your own database at send time? Yes leans toward your own orchestrator. Ingesting the per-channel delivery reports gets you most of the way, but the hop decision itself only lives in the system that made it.
- None of the above? Configure a platform flow, send to it, and spend your engineering time elsewhere.
Running both without stepping on each other
“Both” works fine as long as each message has exactly one owner. Payment alerts go to a platform flow. OTPs go through your orchestrator, which calls the plain channel send endpoints. What you must never do is send an OTP into a platform flow and also have your orchestrator watch it and fire its own fallback. You’d have two systems retrying the same message on two clocks, and the user gets two WhatsApp messages and a phone call.
The same rule shows up in the voice API’s own redial setting. Platform redial and your own retry loop are two owners for one call. The voice article covers that in detail. Here it’s the reason the voice step below sets redial to zero.
Decision Matrix by Message Type {#}
Here’s how the gates usually play out. Treat it as a starting point, then run your own message types through the gates.
| Message type | Typical ladder | Owner | Why |
|---|---|---|---|
| Login or signup OTP | SMS -> WhatsApp -> voice | Your orchestrator | Voice step, 30 second deadlines, code expiry |
| Transaction OTP (payments) | SMS -> voice | Your orchestrator | Voice step, tight deadline, strict audit |
| Payment or debit alert | SMS -> WhatsApp | Platform flow, or orchestrator if WhatsApp opt-in is partial | Only failure-driven hops needed |
| Delivery or appointment update | WhatsApp -> SMS | Platform flow | Slow deadline is fine, failure-driven hops suffice |
| RCS-first rich notification | RCS -> SMS | Platform flow | 5007 “RCS Not Enabled” falls through on empty triggers |
| Account security notice | SMS -> WhatsApp -> Telegram | Your orchestrator | Telegram step |
| Promotional campaign | One channel, no fallback | Neither | Fallback can bill several channels per recipient and adds consent risk |
The last row matches the platform’s own guidance in Can I use Fallback for promotional bulk campaigns?: fallback is for messages where a failed delivery actually hurts.
On RCS-first notifications, the RCS API reference explains why globalErrorCode 5007 is a routing fact rather than a transient error. With empty trigger codes it drops straight through to SMS, which is exactly what you want.
Which Failures Should Move to the Next Channel
Not every failure means the same thing, so not every failure deserves the same response. Sort them into four buckets before you write a line of routing code.
Reach failures: fall back. The channel can’t get to this person right now, but another one might. RCS globalErrorCode 5007 with cause “RCS Not Enabled” is the cleanest example. So is a WhatsApp send to a number with no WhatsApp account, or an SMS that expires undelivered because the handset was off. These are exactly what fallback was built for.
Regulatory routing failures: fall back only through a consent check. NCPR_FAIL and DND_PREFERENCE_NUMBER mean the SMS hit the DND registry or the person’s category preference. That’s a statement about SMS, not about every channel. A hop to WhatsApp is fine if you hold a WhatsApp opt-in for this purpose, and wrong if you don’t. The platform’s own example of narrowing trigger codes to “DND / NCPR only” assumes the next channel is one you’re allowed to use.
Configuration failures: fall back for OTP, and alert a human. TEMPLATE_CHECK_FAILED, SENDERID_MISMATCH and INVALID_SENDERID mean you broke something: a template edit, a sender ID that isn’t linked. The user still needs their code, so let the OTP move on. But treat every one of these as a page-someone event, because otherwise fallback quietly hides a broken first step and you pay for two sends on every message until someone notices the hop rate.
Refusals: stop. The person, your own block list or the regulator has said no. Those get their own section next, because they’re the ones that cause real damage.
Failures That Must Never Trigger Fallback
These four codes have something in common: each one is somebody telling you not to send. Switching channels doesn’t change the answer. It just ignores it somewhere else.
| Code | Where it comes from | What it means | Your response |
|---|---|---|---|
BLOCK_NUMBER | SMS, WhatsApp or voice send to a number on your Blocked Numbers list | You (or your team) blocked this number on this product | Stop. Find out why it was blocked before anything else goes out |
BLACK_LISTED | SMS delivery status | The number is on a blacklist the send can’t pass | Stop and suppress |
CONSENT_FAILED | DLT consent check on SMS | No valid consent on record for this message | Stop. Collect consent properly, then send |
| 131050 | WhatsApp, from Meta’s error code list | The user has stopped marketing messages from your business | Stop marketing on every channel for this person |
Here’s the trap with the platform default. Empty trigger codes mean any failure moves to the next step. For an OTP the user just asked for, that’s what you want nearly every time. For an alert or reminder, it means a blocked or opted-out number gets the message on the next channel anyway.
There are two clean fixes:
- In a platform flow, select explicit trigger codes for non-OTP flows (reach and timeout failures), so refusals don’t qualify.
- In your own orchestrator, keep a
NEVERset and check it before anything else. The Python sample in A Fallback Orchestrator in Python does exactly that, and stops with a reason you can log.
Blocked Numbers is also per product. A number blocked for SMS isn’t automatically blocked for WhatsApp. Your own suppression table should be the authority, as laid out in the opt-out and suppression guide, with the platform list kept in step with it.
Consent Gates Between Steps
A fallback ladder crosses channels, and consent doesn’t travel with it. A customer who gave you their number at checkout may expect a transactional SMS. That doesn’t mean they opted in to WhatsApp. WhatsApp has its own opt-in expectations, covered in the WhatsApp opt-in guide, and your records need to show you collected one.
So before each hop, ask your own data one question: may this person receive this purpose on this channel? The answer comes from three places:
- Suppression: has this person opted out of this channel or of everything? If so, skip.
- Consent: do you hold a grant for this purpose and channel that hasn’t been withdrawn? The consent capture guide covers the event table.
- Reachability: do you even have an identifier for this channel? A Telegram step needs a chat ID, not a phone number.
Turn the answer into an allowed set before you call the router, and let the router skip channels that aren’t in it. Skipping isn’t failing. A ladder of SMS, WhatsApp and voice for a user with no WhatsApp opt-in simply becomes SMS then voice for that user.
Fail closed. If the consent lookup errors or times out, the channel isn’t allowed for this attempt. A delayed OTP that goes out by voice instead of WhatsApp is a much smaller problem than a message you had no right to send.
This is also why the platform flow fits some audiences and not others. If you only send a flow to recipients who’ve already passed your gate on every channel in it, the platform’s “next step” and your “allowed” agree, and the flow is safe. If your audience is mixed, the gate has to live in your code.
Deadlines: Falling Back on Silence
The quiet failure is the one that hurts. A send is accepted, the delivery report says PENDING, and nothing else ever arrives. No failure, so no failure-driven fallback. The user stares at the screen and taps “resend”, and now you have two codes in flight.
Give every step a deadline: if there’s no final outcome by T seconds after the send, treat it as a timeout and move on. Pick T per channel and per purpose.
| Purpose | SMS | RCS | Voice | Why | |
|---|---|---|---|---|---|
| Login OTP | 30 s | 30 s | 30 s | 60 s | A call takes longer to ring and play |
| Transaction OTP | 20 s | n/a | n/a | 60 s | Payment pages time out fast |
| Payment alert | 120 s | 300 s | 300 s | n/a | Minutes matter, seconds don’t |
| Appointment reminder | 15 min | 15 min | 15 min | n/a | Hours of slack before the event |
These are starting points, not truths. Measure your own delivery times by channel and operator, then set each deadline a little above the 95th percentile of successful deliveries. Set it too low and you pay for hops that weren’t needed. Set it too high and the user gives up first.
Add up the ladder against the code’s lifetime. With a 5 minute OTP and deadlines of 30, 30 and 60 seconds, the whole ladder runs out after 120 seconds, leaving plenty of validity on the code when the last step lands. If the deadlines add up to more than the OTP’s validity, the last step delivers a code that’s already dead.
When a deadline fires, the earlier step isn’t cancelled. It may still deliver. That’s fine, and it’s the subject of the next section.
The Same OTP on Every Channel
Here’s the property that makes late deliveries a non-event: generate the code once, then send that same value on every step of the ladder.
Picture it going wrong. SMS goes out with 482910 and sits in PENDING. At 30 seconds your deadline fires and WhatsApp goes out. If you generated a fresh code for WhatsApp, say 771304, and the SMS finally lands at 40 seconds, the user now holds two different codes. Whichever one they type, there’s a fair chance it’s the one you just invalidated. Support ticket.
With one value, the late SMS just repeats what WhatsApp already said. The user types 482910 and it works, whichever message they read. You don’t need to know whether the first step will deliver late. A design that’s correct either way doesn’t have to guess.
The platform flow does this for you through variable mapping from the first step. In your own orchestrator, store the code (hashed) on the message row and pass the same plaintext value to every send.
Keeping one code safe across several channels
A fair objection, raised in this write-up on multi-channel OTP risk: the same code on more channels means more places it can leak. That’s true, and it’s an argument for discipline, not for fresh codes per channel. Keep these rules:
- Sequential only. Never fan the code out to every channel at once “to be safe”. Each hop happens because the previous one failed or went quiet.
- One validity window. The clock starts when the code is generated, not when each step sends. Fallback never extends it.
- One attempt counter. Wrong guesses count against the code, whichever channel it arrived on.
- Burn on first success. The moment verification succeeds, the code is dead, and your router stops any step it hasn’t sent yet.
- Never log the plaintext. Store a hash for verification. Delivery rows, ledger rows and support tickets carry message IDs, not codes.
- Consent-gated channels only. A code shouldn’t land in a channel the user didn’t agree to, which the gate from Consent Gates Between Steps already covers.
Adding Voice OTP as the Last Step
Voice is the step most teams want and the platform flow doesn’t offer, so it’s the most common reason to own the ladder. The call goes through VoiceApi/send with sendMethod=pin, which reads a numeric code aloud using one of the pre-recorded intro clips.
#!/usr/bin/env bash
# Last step of an OTP ladder: read the same code aloud.
# PIN stays a string so a leading zero survives.
set -euo pipefail
PIN="048213"
curl -sS -G "https://unify.smsgateway.center/VoiceApi/send" \
-H "apikey: ${SGC_APIKEY}" \
--data-urlencode "userid=${SGC_USERID}" \
--data-urlencode "sendMethod=pin" \
--data-urlencode "mobile=${RECIPIENT}" \
--data-urlencode "pin=${PIN}" \
--data-urlencode "pinClipId=OneTimePassword" \
--data-urlencode "reDial=0" \
--data-urlencode "output=json"
Get these four things right and the voice step becomes boring, which is exactly what you want.
Send the PIN as a digit string. The voice API’s schema types pin as an integer, and integers lose leading zeros. A code like 048213 must reach the call as six digits, so build the request from the string and never round-trip it through a number. That’s also why the code above is a variable holding text.
Set reDial=0. The voice API can redial on its own (zero to two redials at 5, 10 or 15 minute intervals). For an OTP ladder your orchestrator is the retry owner, and a platform redial 15 minutes later reads out a code that expired 10 minutes ago. One owner, so the platform redial stays off.
Pick the intro clip on purpose. pinClipId values from the voice OTP parameter guide include OneTimePassword, VerificationCode, Default, FollowingCode, Registration and UseThisCode. Match it to the screen the user is looking at.
Save transactionId as text. The reply is flat: status, mobile, invalidMobile, transactionId, statusCode and reason. Voice IDs are 18 or 19 digits, which a double can’t hold exactly, so keep the quoted string end to end. The voice wire contract has the full lossless-parsing treatment.
Give voice the longest deadline on the ladder. A call has to ring, be answered and play before anything can be called delivered. A 60 second budget is a sensible starting point.
The Attempt Ledger
Every fallback system eventually hits the same bug: a worker crashes after calling the provider and before saving the result, the job is retried, and the step goes out twice. The cure is to write down your intent before you call anyone.
Two tables are enough.
-- One row per logical message, whatever channels it ends up on.
CREATE TABLE fallback_message (
message_id TEXT PRIMARY KEY, -- your ID, generated before any send
purpose TEXT NOT NULL, -- 'otp', 'payment_alert', ...
recipient TEXT NOT NULL, -- normalised MSISDN
otp_hash TEXT, -- NULL for non-OTP purposes
created_at TIMESTAMPTZ NOT NULL,
closed_at TIMESTAMPTZ,
close_reason TEXT -- 'delivered', 'expired', 'refused:BLOCK_NUMBER', ...
);
-- One row per rung of the ladder that was actually tried.
CREATE TABLE fallback_attempt (
message_id TEXT NOT NULL REFERENCES fallback_message (message_id),
step INTEGER NOT NULL, -- index into the policy ladder
channel TEXT NOT NULL, -- 'sms', 'whatsapp', 'rcs', 'voice', 'telegram'
provider_id TEXT, -- transactionId / uuId, always text
state TEXT NOT NULL DEFAULT 'reserved',
code TEXT, -- normalised failure code
reserved_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
settled_at TIMESTAMPTZ,
PRIMARY KEY (message_id, step)
);
CREATE INDEX fallback_attempt_provider ON fallback_attempt (provider_id);
The lifecycle of an attempt row is short:
- reserved. Insert the row. If the insert hits the primary key, someone else already owns this step, so walk away.
- pending. The provider accepted the send. Store its ID, as text.
- delivered or failed. A delivery report arrived. Store the normalised code.
A row stuck in reserved means a worker died mid-call. You don’t know whether the provider got the request. Treat it like a pending step with reserved_at as its send time: let its deadline run out, then move to the next step. Never resend the same step. A duplicate on the same channel is exactly what the ledger exists to prevent, and the idempotency guide covers the provider-side half of the same problem.
One more rule: a delivered row never goes back to failed. Reports can arrive out of order, and a late failure for a step that already delivered is noise. A late delivery for a step you’d marked failed is real, though, so let it upgrade the row.
A Fallback Orchestrator in Python
Here’s the whole router: a policy table, a pure decide() function, the ledger writes, and a run_once() driver that advances one message by at most one step. It runs on Python 3.10 with nothing but the standard library. Swap SQLite for your real database and the senders for your channel clients.
Call run_once() from two places: right after a delivery report for that message lands, and from a timer that sweeps open messages every few seconds so deadlines fire even when nothing reports.
import sqlite3
import time
from dataclasses import dataclass
# One policy per message purpose. Order matters: index 0 is tried first.
POLICIES = {
"otp": {
"ladder": ["sms", "whatsapp", "voice"],
"deadline_s": {"sms": 30, "whatsapp": 30, "voice": 60},
"fallback_on": None, # None means any failure not in NEVER
"max_age_s": 300, # the OTP itself expires after 5 minutes
},
"payment_alert": {
"ladder": ["sms", "whatsapp"],
"deadline_s": {"sms": 120, "whatsapp": 300},
"fallback_on": {"NCPR_FAIL", "DND_PREFERENCE_NUMBER", "TIMEOUT"},
"max_age_s": 3600,
},
}
# Codes that mean "this person said no" or "you are not allowed".
# Moving to another channel would ignore that answer.
NEVER = {"BLOCK_NUMBER", "BLACK_LISTED", "CONSENT_FAILED", "WA_131050"}
# Codes that mean you broke something. Fall back, but tell a human.
CONFIG_FAILURES = {"TEMPLATE_CHECK_FAILED", "SENDERID_MISMATCH", "INVALID_SENDERID"}
@dataclass
class Attempt:
step: int
channel: str
sent_at: float
state: str # "pending" | "delivered" | "failed"
code: str | None # normalised failure code, or None
def decide(purpose, attempts, now, created_at, allowed):
"""Return ("wait", None), ("send", channel) or ("stop", reason).
attempts: list of Attempt, oldest first.
allowed: set of channels this recipient may receive this purpose on,
read from your own consent and suppression tables.
"""
policy = POLICIES[purpose]
ladder = policy["ladder"]
if any(a.state == "delivered" for a in attempts):
return ("stop", "delivered")
if now - created_at > policy["max_age_s"]:
return ("stop", "expired")
if attempts:
last = attempts[-1]
if last.state == "failed" and last.code in NEVER:
return ("stop", "refused:" + last.code)
if last.state == "pending":
if now - last.sent_at < policy["deadline_s"][last.channel]:
return ("wait", None)
trigger = "TIMEOUT"
else:
trigger = last.code or "UNKNOWN_FAILURE"
wanted = policy["fallback_on"]
if wanted is not None and trigger not in wanted:
return ("stop", "not-a-trigger:" + trigger)
start = last.step + 1
else:
start = 0
for step in range(start, len(ladder)):
if ladder[step] in allowed:
return ("send", ladder[step])
return ("stop", "ladder-exhausted")
SCHEMA = """
CREATE TABLE IF NOT EXISTS fallback_attempt (
message_id TEXT NOT NULL,
step INTEGER NOT NULL,
channel TEXT NOT NULL,
provider_id TEXT,
state TEXT NOT NULL DEFAULT 'reserved',
code TEXT,
reserved_at REAL NOT NULL,
sent_at REAL,
settled_at REAL,
PRIMARY KEY (message_id, step)
);
"""
def reserve(db, message_id, step, channel, now):
"""Claim a step before calling the provider. False means someone else has it."""
try:
with db:
db.execute(
"INSERT INTO fallback_attempt (message_id, step, channel, reserved_at)"
" VALUES (?, ?, ?, ?)",
(message_id, step, channel, now),
)
return True
except sqlite3.IntegrityError:
return False
def mark_sent(db, message_id, step, provider_id, now):
with db:
db.execute(
"UPDATE fallback_attempt SET state = 'pending', provider_id = ?, sent_at = ?"
" WHERE message_id = ? AND step = ? AND state = 'reserved'",
(str(provider_id), now, message_id, step),
)
def settle(db, provider_id, state, code, now):
"""Apply a delivery outcome. A delivered row is never downgraded."""
with db:
db.execute(
"UPDATE fallback_attempt SET state = ?, code = ?, settled_at = ?"
" WHERE provider_id = ?"
" AND (state = 'pending' OR (state = 'failed' AND ? = 'delivered'))",
(state, code, now, str(provider_id), state),
)
def run_once(db, msg, now, allowed_for, senders, alert):
"""Advance one message by at most one step. Call it from a timer or after
every delivery report for this message."""
rows = db.execute(
"SELECT step, channel, COALESCE(sent_at, reserved_at), state, code"
" FROM fallback_attempt WHERE message_id = ? ORDER BY step",
(msg["message_id"],),
).fetchall()
attempts = [
Attempt(s, ch, t, "pending" if st == "reserved" else st, code)
for s, ch, t, st, code in rows
]
action, arg = decide(msg["purpose"], attempts, now, msg["created_at"],
allowed_for(msg))
if action != "send":
return action, arg
channel = arg
step = POLICIES[msg["purpose"]]["ladder"].index(channel)
if not reserve(db, msg["message_id"], step, channel, now):
return "wait", None # another worker owns this step
if attempts and attempts[-1].code in CONFIG_FAILURES:
alert(msg["message_id"], attempts[-1].code)
provider_id = senders[channel](msg) # returns the ID as a string
mark_sent(db, msg["message_id"], step, provider_id, now)
return "sent", channel
What it handles, scenario by scenario (each one was run against an in-memory database with fake senders):
| Scenario | What run_once() does |
|---|---|
| First call for a new OTP | Reserves step 0 and sends SMS |
SMS still PENDING at 10 s | Returns wait |
SMS failed with TEMPLATE_CHECK_FAILED, user has no WhatsApp opt-in | Skips WhatsApp, sends voice, raises an alert |
| SMS delivers late while voice is pending | Stops with “delivered” and sends nothing more |
| Worker died after reserving SMS, before saving the reply | Waits out the SMS deadline, then sends WhatsApp, never a second SMS |
SMS fails with BLOCK_NUMBER | Stops with “refused:BLOCK_NUMBER” |
Payment alert fails with a code that isn’t in fallback_on | Stops with “not-a-trigger” |
| OTP older than five minutes | Stops with “expired” |
Why it’s built this way:
decide() is pure. It takes attempts, a clock and an allowed set, and returns an action. No I/O, no globals beyond the policy table. That’s what makes the table above cheap to test, and you should keep it that way as you add rules.
The allowed_for() callback is your consent gate. It should read suppression and consent and return the set of channels permitted for this purpose. If it raises, let the exception propagate and retry the message later. Don’t catch it and return every channel.
senders[channel] must return the provider’s ID as a string. For SMS and voice that’s transactionId. Never let a JSON parser turn it into a float on the way. Then key settle() on the same string when the delivery report arrives.
Normalise codes before they reach the router. WhatsApp’s 131050 becomes WA_131050 so it can’t collide with a numeric code from another channel. RCS 5007 can become RCS_5007 the same way.
Reading Outcomes Per Channel
The router only understands three states (pending, delivered, failed) plus a normalised code. Each channel reports differently, so write one small adapter per channel that turns a delivery row into that shape.
| Channel | Where outcomes come from | Fields to read | Watch out for |
|---|---|---|---|
| SMS | SMSApi/reports/status with method=getDlr, or the account’s single SMS webhook | uuId, mobileNo, submitTime, status (DELIVERED, FAILED, PENDING) | Treat status as an open set. Anything unknown maps to pending |
WAApi/report (POST, wabaNumber required) | uuId, mobileNo, status and Meta error code | uuId and mobileNo arrive unquoted, so parse them losslessly | |
| RCS | RCS delivery report | deliveryStatus, globalErrorCode, cause | 5007 “RCS Not Enabled” is a reach failure, not a retry |
| Voice | VoiceApi/report/dlr with tId=true and the uuid | status, cause, globalErrorCode, answeredDuration | “SUBMITTED” with “Waiting For Report” is still pending |
Two rules apply to all four adapters.
Unknown means pending, not failed. If a status shows up that your adapter doesn’t recognise, return pending. The deadline will move the message on if nothing better arrives. Mapping unknowns to failed makes every new status the provider introduces trigger an immediate extra send.
Key on the provider ID you stored. The ledger row has the ID from the send reply. Match delivery rows to it as a string, and for SMS keep the full composite (ID, number, submit time, status) that the delivery report ingestion guide recommends, so a status transition never overwrites another.
Cost: Every Hop Is a Billed Send
Fallback isn’t free, and it’s worth being exact about why. On SMS, the per-message rate comes out of your wallet when the message is sent, and credits aren’t refunded once a message has been submitted to the operator. So a failed SMS step still costs a full SMS. The fallback is a second send on top.
The expected cost per logical message is the first step’s price plus each later step’s price weighted by how often you reach it:
expected_cost = c1 + p1 * c2 + p1 * p2 * c3 + ...
c_n = price of step n
p_n = share of messages that fall past step n
A worked example with the per-message rates listed for the smallest packs: OTP SMS at INR 0.17 on the OTP SMS pricing page and RCS at INR 0.15 on the RCS pricing page, both before GST. Suppose 10% of messages fall past SMS. Your hop rate is something to measure, not assume, and 10% here is just an input:
| Ladder | Expected cost per message | Extra spend per 10,000 messages |
|---|---|---|
| SMS only | INR 0.17 | none |
| SMS -> RCS at a 10% hop rate | INR 0.17 + 0.10 x 0.15 = INR 0.185 | 1,000 RCS sends x INR 0.15 = INR 150 |
So that ladder costs 15 paise for each rescued message. Weigh that against what a lost login or a missed payment alert costs you, and check the arithmetic against your own pack price, since pack totals and per-message rates can round differently.
Two cost traps to design out:
- Deadlines that are too short. Every premature timeout is a hop you paid for and didn’t need, plus a duplicate the user sees. Tune deadlines from measured delivery times.
- Broken first steps. A template or sender ID fault sends 100% of messages down the ladder. That’s why configuration failures raise an alert in the orchestrator above. A hop rate that jumps overnight is almost always one of these.
For a channel whose per-message price you haven’t confirmed in your own rate card, leave it out of the calculation rather than guessing, and reconcile actual spend later with the approach in the invoice reconciliation guide.
Measuring Fallback: Hop Rate and Time to Deliver
Two numbers tell you almost everything about a ladder: how often each step hands off, and how long delivery takes from the first send. Get both per purpose and per channel position, daily.
The platform’s Failover Analytics gives you the platform-side view for flows it runs: total sent, delivered, failover-assisted deliveries and success by step position. For your own orchestrator, the ledger already holds everything. This query gives hop rate and delivery share by step for the last day:
SELECT
m.purpose,
a.step,
a.channel,
COUNT(*) AS attempts,
SUM(CASE WHEN a.state = 'delivered' THEN 1 ELSE 0 END) AS delivered,
ROUND(1.0 * SUM(CASE WHEN a.state = 'delivered' THEN 1 ELSE 0 END)
/ COUNT(*), 3) AS delivered_share
FROM fallback_attempt a
JOIN fallback_message m ON m.message_id = a.message_id
WHERE m.created_at >= :since
GROUP BY m.purpose, a.step, a.channel
ORDER BY m.purpose, a.step;
Read it like this. If step 0 delivers 97% of the time, your hop rate is about 3% and fallback is a safety net. If step 0 delivers 60%, your first channel is the wrong first channel for this audience, and reordering the ladder will save money and seconds.
Track these alongside it:
- Time to deliver, p50 and p95, from the first send. This is what the user feels. A ladder that delivers 99.5% but at a p95 of 90 seconds is losing logins.
- Hop reason mix. Timeout versus reach failure versus configuration failure. A rise in configuration failures is a bug, not a channel problem.
- Refusal stops. Messages stopped on
BLOCK_NUMBER,BLACK_LISTED,CONSENT_FAILEDor 131050. These should be rare and they should never be zero-cost surprises in a campaign. - Double deliveries. Messages with more than one delivered attempt. Some are expected, since that’s what a deadline does. A sharp rise means a deadline is set too short.
- Cost per delivered message. Total spend across all attempts divided by messages that reached someone.
These fit into the pipeline metrics in the observability guide. Put the hop rate on the same dashboard as send latency, because a sudden jump in either is usually the first sign of an incident.
Rollout Checklist
Work through this in order. Each item is cheap on its own and expensive to skip.
- List every message type and run it through the five gates in The Gated Decision. Write the owner next to each one.
- For platform flows: confirm Failover is enabled on your account and that at least two of SMS, WhatsApp and RCS are licensed and allowed for your login.
- For platform flows used for anything other than OTP: select explicit trigger codes, so refusals don’t move on.
- Prepare an approved template per channel per ladder. Keep the template keys in your own registry, as the template management guide suggests.
- Map every variable from the first step, and make sure the OTP is mapped from source, never from static text.
- Store each platform flow’s exact name in config and reference it from one place in your code.
- For your orchestrator: create
fallback_messageandfallback_attemptwith the primary key on (message, step). - Write the
allowed_for()consent gate against your suppression and consent tables, and make it fail closed. - Set per-channel deadlines and check that the ladder total fits inside the OTP’s validity.
- Add the
NEVERset and the configuration-failure alert. - Set
reDial=0on voice OTP calls and sendpinas a string. - Build one delivery adapter per channel that maps unknown statuses to pending.
- Send one test message per ladder to a number you control and walk it through every step by forcing failures. Check each hop in your ledger and, for platform flows, in Fallback Delivery Reports.
- Ship the hop rate, time-to-deliver and double-delivery metrics before you send real traffic.
- Review the metrics after the first week and adjust deadlines and ladder order.
Common Mistakes
Falling back on everything for every purpose. Empty trigger codes are right for OTP. On a reminder or alert they move refusals to the next channel too. Narrow the triggers anywhere the user didn’t just ask for the message.
Generating a new OTP per channel. The late first message then carries a dead code. One value, one window, one counter.
Two retry owners. A platform flow plus your own watcher on the same message, or your orchestrator plus voice redial. Every message gets exactly one owner.
No deadline. Waiting for a failure that never comes. The OTP expires while the ladder is still on step one.
Deadlines longer than the code. A voice call that lands after the code has expired is worse than no call, because the user now types a code that’s rejected.
Resending the same step after a crash. A reserved row with no provider ID means “unknown”, not “not sent”. Move to the next step when the deadline passes.
Treating unknown statuses as failures. Every new status string becomes an instant extra send. Unknown should mean pending.
Storing transaction IDs as numbers. Eighteen and nineteen-digit IDs don’t survive a double. Your settle() never matches and every message looks pending forever.
Skipping the consent check because “it’s only a fallback”. A fallback message is still a message on that channel. It needs the same permission as a first send.
Using fallback for promotions. It can bill several channels per recipient and pushes marketing into channels where the person never opted in.
Unspecified Behaviour and How to Code Around It
A few behaviours that matter for fallback aren’t pinned down anywhere you can read. Each item below gives the choice that stays correct whichever way the platform actually behaves, and none of them needs you to wait for an answer before you build.
One. Send the same code on every step, so a late delivery never matters. Nobody can promise you that a step which missed its deadline won’t deliver a minute later. With one OTP value down the whole ladder, a late arrival repeats what the user already has. Correct if it arrives, correct if it doesn’t.
Two. Give every step a deadline of your own. How long a platform flow waits on a step that stays pending before it moves on isn’t something you can look up. For orchestrated messages your deadline decides, so the question goes away. For platform flows, force a silent first step in a test (an RCS-first flow to a handset without RCS works well) and read the hop timing in Fallback Delivery Reports before you trust it with OTPs.
Three. Prove a flow’s name binding with one test send before production. “Use the flow name in your API” tells you the name is the handle, not which request field carries it on each channel. Send one message through the flow to a number you control with the first step guaranteed to fail, and check that Delivery Reports show the hop under that flow name. Until that test passes, treat the flow as not wired up.
Four. Keep refusals out of every non-OTP ladder with explicit trigger codes or a NEVER set. Whether an empty trigger list treats BLOCK_NUMBER or BLACK_LISTED as a failure that moves on, you don’t have to find out. Explicit trigger codes on platform flows and a NEVER check in your own router stop refusals either way.
Five. Budget every attempt as billed, including the ones that fail. SMS is charged when sent and isn’t refunded after submission. For the other channels, assume the same until your own invoices show otherwise. A ladder budgeted that way can only come in under plan.
Six. Map statuses you don’t recognise to pending and let the deadline decide. Status lists across four channels aren’t closed, and new values appear. Pending plus a deadline gives a delayed hop for an unknown value, never an instant duplicate.
Seven. Never resend a step whose outcome you can’t see. Move to the next one. If a worker died between calling the provider and saving the reply, you don’t know whether the message went out. Moving to the next rung after the deadline is safe whether the first send happened or not. Resending the same rung is only safe if it didn’t.
Eight. Let a late delivery upgrade an attempt, and never let a late failure downgrade one. Reports from different channels, and sometimes from one channel, can arrive out of order. With that one-way rule in settle(), the order they arrive in stops mattering.
Nine. Check your own consent tables before every hop, and send platform flows only to audiences that already pass every step. Whether the platform consults any opt-in record when it moves a message to WhatsApp or RCS, a recipient who passed your gate for every channel in the flow is safe either way.
Ten. Keep voice redial off on OTP steps and own the retry yourself. With reDial=0, the question of exactly when a platform redial would fire never comes up, and a dead code is never read aloud.
Eleven. Roll out flow changes as a new flow name, not an edit to a live one. What an edit or a disable does to messages already partway down a ladder isn’t something you can count on. Create the new flow, test it, switch the name in your config, then disable the old one once its traffic has drained. In-flight messages finish on the flow they started on.
Twelve. Keep provider IDs as strings from the send reply to the delivery adapter. Some replies quote 19-digit IDs and some don’t. A lossless parse and a text column match either way, while a double silently breaks the join on half of them.
FAQs
Does the platform’s Multi-Channel Fallback support voice calls?
No. Platform flows cover SMS, WhatsApp Business API and RCS. Voice, Telegram and email aren’t part of the feature, so a ladder that ends in a voice OTP has to be run by your own orchestrator using the Voice Call API.
Should I leave trigger error codes empty?
For OTP, yes. Any failure on a step moves to the next channel, which gives the most coverage when the user is waiting for a code they asked for. For alerts and reminders, pick explicit codes such as DND and timeout failures, so a blocked or opted-out number doesn’t get the message on the next channel.
What happens if the SMS arrives after the WhatsApp message already went out?
The user gets the same message twice. If you sent the same OTP value on both steps, that’s harmless, because either message carries the working code. That’s why you generate the code once and never per channel.
How long should I wait before falling back?
Start around 30 seconds per step for login OTPs and 60 seconds for a voice call, then tune from your own delivery times. Set each deadline a little above the 95th percentile of successful deliveries on that channel, and make sure the whole ladder fits inside the OTP’s validity.
Is it safe to send the same OTP on several channels?
Yes, as long as you keep it disciplined: sequential hops only, one validity window from generation, one shared attempt counter, the code burned on first successful use, and never logged in plain text. Don’t fan it out to every channel at once.
Do I need a separate template for each channel?
Yes. Every step needs its own approved template: a DLT-registered template and sender ID for SMS, a Meta-approved template for WhatsApp, and an RCS template for RCS. Variable mapping copies values like the OTP from the first step into the others.
Is a failed SMS step still charged?
Yes. The per-SMS rate comes out of your wallet when the message is sent, and credits aren’t refunded once it’s submitted to the operator. A fallback after a failed SMS is a second paid send on top of the first.
Can I use the platform flow and my own orchestrator together?
Yes, as long as each message type has one owner. A common split is platform flows for payment alerts and delivery updates, and your own orchestrator for OTPs. Never send a message into a platform flow and also run your own fallback for that same message.
Should I use fallback for promotional campaigns?
No. Fallback suits messages where a failed delivery has a real cost, like OTPs and payment alerts. On promotions it can bill several channels per recipient and pushes marketing into channels where the person may never have opted in.
Build a fallback ladder that never double-sends.
Try SMS, WhatsApp, RCS and voice OTP side by side in the free sandbox, read the Multi-Channel Fallback overview to see what the platform flow handles for you, or talk to the team about enabling failover on your account.
More from our blog
Reconciling a Messaging Invoice Across Four Channels: Credits, Currency and the Rate Plan API
WhatsApp Business API Wire Contract: Ten Endpoints, Two Base Paths, Five Envelopes
Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports