SMSGatewayCenter Blog

Multi-Channel Fallback Design: Platform Failover or Your Own Orchestrator for SMS, WhatsApp, RCS and Voice OTP

When should a failed SMS move to WhatsApp, RCS or a voice call, and who should make that call: the platform's fallback flow or your own code? A practical design guide covering trigger codes, failures that must never fall back, consent gates between steps, deadlines for silent channels, the same-OTP rule, an attempt ledger and a tested Python orchestrator.

Featured image for Multi-Channel Fallback Design: Platform Failover or Your Own Orchestrator for SMS, WhatsApp, RCS and Voice OTP
Abstract ladder of four coloured platforms with a glowing line stepping down from one to the next and stopping at the third.
Fallback is a ladder: each step only runs when the one above it has clearly failed or gone quiet for too long.

Table of Contents

  1. The Short Answer
  2. TL;DR
  3. What the Platform Fallback Flow Does
  4. Where the Platform Flow Stops
  5. The Gated Decision: Platform, Orchestrator or Both
  6. Decision Matrix by Message Type
  7. Which Failures Should Move to the Next Channel
  8. Failures That Must Never Trigger Fallback
  9. Consent Gates Between Steps
  10. Deadlines: Falling Back on Silence
  11. The Same OTP on Every Channel
  12. Adding Voice OTP as the Last Step
  13. The Attempt Ledger
  14. A Fallback Orchestrator in Python
  15. Reading Outcomes Per Channel
  16. Cost: Every Hop Is a Billed Send
  17. Measuring Fallback: Hop Rate and Time to Deliver
  18. Rollout Checklist
  19. Common Mistakes
  20. Unspecified Behaviour and How to Code Around It
  21. 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_FAILED and 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 PENDING that 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:

RuleWhat it means for your design
Channels are SMS, WhatsApp Business API and RCSVoice, email and Telegram can’t be steps in a platform flow
At least two eligible channels on your accountWith one channel, the Create button stays hidden
Each channel appears once per flowNo “SMS, WhatsApp, SMS again” ladders
Top step is tried first, bottom step is the last attemptOrder is the main lever you have
One approved template per channelDLT 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 mappingEvery placeholder on a later step is filled from a first-step variable or from static text
Trigger codes per step, except the lastEmpty means any failure moves on. Selected codes mean only those do
Status switch pauses a flow, Delete removes itDisable during template changes, recreate only when you mean to
Delivery Reports show each attemptSearch by date, number, channel or error code, and see whether failover ran
Failover Analytics by date or flow nameTotal 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.

  1. Does the ladder include voice or Telegram? Yes means your own orchestrator.
  2. Does a silent step need to move on before it fails? Yes means your own orchestrator. OTP almost always lands here.
  3. 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.
  4. 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.
  5. None of the above? Configure a platform flow, send to it, and spend your engineering time elsewhere.
Three-panel flow. Panel one: the gate questions about voice, Telegram, deadlines, consent and audit. Panel two: platform flow across SMS, WhatsApp and RCS with trigger codes. Panel three: your own orchestrator with a ledger, deadlines, consent checks and a voice OTP last step. Banners below state the three rules that apply to both.
Run each message type through the gates. Whatever answers first decides who owns the ladder.

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 typeTypical ladderOwnerWhy
Login or signup OTPSMS -> WhatsApp -> voiceYour orchestratorVoice step, 30 second deadlines, code expiry
Transaction OTP (payments)SMS -> voiceYour orchestratorVoice step, tight deadline, strict audit
Payment or debit alertSMS -> WhatsAppPlatform flow, or orchestrator if WhatsApp opt-in is partialOnly failure-driven hops needed
Delivery or appointment updateWhatsApp -> SMSPlatform flowSlow deadline is fine, failure-driven hops suffice
RCS-first rich notificationRCS -> SMSPlatform flow5007 “RCS Not Enabled” falls through on empty triggers
Account security noticeSMS -> WhatsApp -> TelegramYour orchestratorTelegram step
Promotional campaignOne channel, no fallbackNeitherFallback 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.

Table with six rows: DELIVERED, PENDING past deadline, NCPR_FAIL, RCS 5007, BLOCK_NUMBER and WhatsApp 131050. Columns show what each means, whether it's a failure, what an OTP ladder does, what an alert ladder does, and what to record. Banners note that refusals stop the ladder and silence is handled by your deadline.
Six outcomes, six different responses. Only reach failures move on without a second thought.

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.

CodeWhere it comes fromWhat it meansYour response
BLOCK_NUMBERSMS, WhatsApp or voice send to a number on your Blocked Numbers listYou (or your team) blocked this number on this productStop. Find out why it was blocked before anything else goes out
BLACK_LISTEDSMS delivery statusThe number is on a blacklist the send can’t passStop and suppress
CONSENT_FAILEDDLT consent check on SMSNo valid consent on record for this messageStop. Collect consent properly, then send
131050WhatsApp, from Meta’s error code listThe user has stopped marketing messages from your businessStop 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 NEVER set 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.


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:

  1. Suppression: has this person opted out of this channel or of everything? If so, skip.
  2. Consent: do you hold a grant for this purpose and channel that hasn’t been withdrawn? The consent capture guide covers the event table.
  3. 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.

PurposeSMSWhatsAppRCSVoiceWhy
Login OTP30 s30 s30 s60 sA call takes longer to ring and play
Transaction OTP20 sn/an/a60 sPayment pages time out fast
Payment alert120 s300 s300 sn/aMinutes matter, seconds don’t
Appointment reminder15 min15 min15 minn/aHours 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:

  1. reserved. Insert the row. If the insert hits the primary key, someone else already owns this step, so walk away.
  2. pending. The provider accepted the send. Store its ID, as text.
  3. 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):

ScenarioWhat run_once() does
First call for a new OTPReserves step 0 and sends SMS
SMS still PENDING at 10 sReturns wait
SMS failed with TEMPLATE_CHECK_FAILED, user has no WhatsApp opt-inSkips WhatsApp, sends voice, raises an alert
SMS delivers late while voice is pendingStops with “delivered” and sends nothing more
Worker died after reserving SMS, before saving the replyWaits out the SMS deadline, then sends WhatsApp, never a second SMS
SMS fails with BLOCK_NUMBERStops with “refused:BLOCK_NUMBER”
Payment alert fails with a code that isn’t in fallback_onStops with “not-a-trigger”
OTP older than five minutesStops 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.

ChannelWhere outcomes come fromFields to readWatch out for
SMSSMSApi/reports/status with method=getDlr, or the account’s single SMS webhookuuId, mobileNo, submitTime, status (DELIVERED, FAILED, PENDING)Treat status as an open set. Anything unknown maps to pending
WhatsAppWAApi/report (POST, wabaNumber required)uuId, mobileNo, status and Meta error codeuuId and mobileNo arrive unquoted, so parse them losslessly
RCSRCS delivery reportdeliveryStatus, globalErrorCode, cause5007 “RCS Not Enabled” is a reach failure, not a retry
VoiceVoiceApi/report/dlr with tId=true and the uuidstatus, 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:

LadderExpected cost per messageExtra spend per 10,000 messages
SMS onlyINR 0.17none
SMS -> RCS at a 10% hop rateINR 0.17 + 0.10 x 0.15 = INR 0.1851,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_FAILED or 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_message and fallback_attempt with 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 NEVER set and the configuration-failure alert.
  • Set reDial=0 on voice OTP calls and send pin as 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

Telegram Messaging API: Eight Endpoints, the Chat ID Model, and the Error Shape That Breaks Typed Clients

Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports

Data Retention and Erasure for SMS, WhatsApp, RCS and Telegram: Deleting a Person Without Breaking Opt-Outs


Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!