{"id":3124,"date":"2026-10-28T09:10:52","date_gmt":"2026-10-28T03:40:52","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3124"},"modified":"2026-10-28T09:10:53","modified_gmt":"2026-10-28T03:40:53","slug":"multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design\/","title":{"rendered":"Multi-Channel Fallback Design: Platform Failover or Your Own Orchestrator for SMS, WhatsApp, RCS and Voice OTP"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">When should a failed SMS move to WhatsApp, RCS or a voice call, and who should make that call: the platform&#8217;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.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design-1024x584.webp\" alt=\"Abstract ladder of four coloured platforms with a glowing line stepping down from one to the next and stopping at the third.\" class=\"wp-image-3125\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">Fallback is a ladder: each step only runs when the one above it has clearly failed or gone quiet for too long.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#platform-flow\">What the Platform Fallback Flow Does<\/a><\/li>\n\n\n\n<li><a href=\"#where-it-stops\">Where the Platform Flow Stops<\/a><\/li>\n\n\n\n<li><a href=\"#gated-decision\">The Gated Decision: Platform, Orchestrator or Both<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix by Message Type<\/a><\/li>\n\n\n\n<li><a href=\"#which-failures\">Which Failures Should Move to the Next Channel<\/a><\/li>\n\n\n\n<li><a href=\"#never-fallback\">Failures That Must Never Trigger Fallback<\/a><\/li>\n\n\n\n<li><a href=\"#consent-gates\">Consent Gates Between Steps<\/a><\/li>\n\n\n\n<li><a href=\"#deadlines\">Deadlines: Falling Back on Silence<\/a><\/li>\n\n\n\n<li><a href=\"#same-otp\">The Same OTP on Every Channel<\/a><\/li>\n\n\n\n<li><a href=\"#voice-step\">Adding Voice OTP as the Last Step<\/a><\/li>\n\n\n\n<li><a href=\"#attempt-ledger\">The Attempt Ledger<\/a><\/li>\n\n\n\n<li><a href=\"#orchestrator-python\">A Fallback Orchestrator in Python<\/a><\/li>\n\n\n\n<li><a href=\"#reading-outcomes\">Reading Outcomes Per Channel<\/a><\/li>\n\n\n\n<li><a href=\"#fallback-cost\">Cost: Every Hop Is a Billed Send<\/a><\/li>\n\n\n\n<li><a href=\"#measuring-fallback\">Measuring Fallback: Hop Rate and Time to Deliver<\/a><\/li>\n\n\n\n<li><a href=\"#rollout-checklist\">Rollout Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#common-mistakes\">Common Mistakes<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Use the platform&#8217;s <a href=\"https:\/\/www.smsgatewaycenter.com\/multi-channel-fallback\/\">Multi-Channel Fallback<\/a> flow when your ladder only uses SMS, WhatsApp and RCS, and &#8220;move on when this step fails&#8221; 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&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Whichever you pick, three rules don&#8217;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.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The platform flow<\/strong> 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.<\/li>\n\n\n\n<li><strong>It stops at three channels.<\/strong> Voice, Telegram and email aren&#8217;t part of it. It also moves on when a step <em>fails<\/em>, so you need your own answer for a step that never reports at all.<\/li>\n\n\n\n<li><strong>The decision is a gate, not a preference.<\/strong> 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.<\/li>\n\n\n\n<li><strong>Some failures must never fall back:<\/strong> <code>BLOCK_NUMBER<\/code>, <code>BLACK_LISTED<\/code>, <code>CONSENT_FAILED<\/code> and WhatsApp error 131050. Each one is an answer from the recipient or the regulator, and switching channels ignores it.<\/li>\n\n\n\n<li><strong>Check consent before every hop.<\/strong> Permission to text someone isn&#8217;t permission to message them on WhatsApp.<\/li>\n\n\n\n<li><strong>Fall back on silence, not just failure.<\/strong> A <code>PENDING<\/code> that never turns into anything is the most common way an OTP goes missing. Give each step a deadline.<\/li>\n\n\n\n<li><strong>Send one OTP value down the whole ladder.<\/strong> If SMS delivers after WhatsApp already has, the user sees the same code twice, and nothing breaks.<\/li>\n\n\n\n<li><strong>Reserve each step in a ledger before you call the provider.<\/strong> A unique key on (message, step) means a crash or retry can&#8217;t send the same step twice.<\/li>\n\n\n\n<li><strong>Every hop is a billed send.<\/strong> SMS is charged at submission, failed or not, so the ladder costs the first step plus every fallback that runs.<\/li>\n\n\n\n<li><strong>Measure hop rate and time to deliver per step.<\/strong> Those two numbers tell you whether the ladder order is right.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"platform-flow\" class=\"wp-block-heading\">What the Platform Fallback Flow Does<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start with what you get for free, because it&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here are the rules it runs by, all taken from the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-is-multi-channel-fallback\/\">Multi-Channel Fallback knowledge base<\/a>:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Rule<\/th><th>What it means for your design<\/th><\/tr><\/thead><tbody><tr><td>Channels are SMS, WhatsApp Business API and RCS<\/td><td>Voice, email and Telegram can&#8217;t be steps in a platform flow<\/td><\/tr><tr><td>At least two eligible channels on your account<\/td><td>With one channel, the Create button stays hidden<\/td><\/tr><tr><td>Each channel appears once per flow<\/td><td>No &#8220;SMS, WhatsApp, SMS again&#8221; ladders<\/td><\/tr><tr><td>Top step is tried first, bottom step is the last attempt<\/td><td>Order is the main lever you have<\/td><\/tr><tr><td>One approved template per channel<\/td><td>DLT template plus sender ID for SMS, a Meta-approved template for WhatsApp, an RCS template for RCS<\/td><\/tr><tr><td>The first channel is the source for variable mapping<\/td><td>Every placeholder on a later step is filled from a first-step variable or from static text<\/td><\/tr><tr><td>Trigger codes per step, except the last<\/td><td>Empty means any failure moves on. Selected codes mean only those do<\/td><\/tr><tr><td>Status switch pauses a flow, Delete removes it<\/td><td>Disable during template changes, recreate only when you mean to<\/td><\/tr><tr><td>Delivery Reports show each attempt<\/td><td>Search by date, number, channel or error code, and see whether failover ran<\/td><\/tr><tr><td>Failover Analytics by date or flow name<\/td><td>Total sent, delivered, failover-assisted, success by step position<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two of them catch people out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Empty trigger codes.<\/strong> The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-are-trigger-error-codes-in-multi-channel-fallback\/\">trigger error codes guide<\/a> recommends leaving them empty for OTP and critical alerts, so any delivery failure moves to the next channel. That&#8217;s the right default for coverage. It also means <em>every<\/em> failure moves on, including the handful you&#8217;d rather stop on. More on that in <a href=\"#never-fallback\">Failures That Must Never Trigger Fallback<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Variable mapping from the first step.<\/strong> On the platform, the first channel&#8217;s template is where values come from. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-map-an-otp-from-sms-to-whatsapp-in-fallback\/\">OTP mapping guide<\/a> shows the pattern: SMS template &#8220;Your OTP is {#var#}. Valid 5 min.&#8221;, WhatsApp template &#8220;Your verification code is {{1}}.&#8221;, and <code>{{1}}<\/code> mapped to the SMS variable. Every step shows the same code. Keep that property if you write your own orchestrator, too. It&#8217;s what makes late deliveries harmless.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The product page sums up the send side in one line: &#8220;Use the flow name in your API or panel sends.&#8221; Keep that exact name string in your config, and treat it like any other identifier your code depends on.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"where-it-stops\" class=\"wp-block-heading\">Where the Platform Flow Stops<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A voice OTP step.<\/strong> 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&#8217;t a platform fallback channel. If you want it on the ladder, your code has to place the call through the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">Voice Call API<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A Telegram step.<\/strong> Same story. Telegram isn&#8217;t part of the feature, so a ladder that includes it is your ladder.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Silence.<\/strong> The flow moves on when a step fails. A step that sits in <code>PENDING<\/code> and never reports is a different problem. An OTP that hasn&#8217;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 &#8220;no answer by time T&#8221;. See <a href=\"#deadlines\">Deadlines: Falling Back on Silence<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Consent per hop.<\/strong> The platform tries the next channel because it&#8217;s next. It can&#8217;t know that this user agreed to SMS alerts but never opted in to WhatsApp. Your consent tables can. See <a href=\"#consent-gates\">Consent Gates Between Steps<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Your own record of each attempt.<\/strong> 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&#8217;ll be building that ledger anyway. Once you&#8217;ve built it, the orchestrator is a small step further.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of this makes the platform flow worse. It just has a clear shape, and some messages don&#8217;t fit it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"gated-decision\" class=\"wp-block-heading\">The Gated Decision: Platform, Orchestrator or Both<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Treat this as a set of gates rather than a taste question. Run each message type through them, in order. The first &#8220;yes&#8221; decides.<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Does the ladder include voice or Telegram?<\/strong> Yes means your own orchestrator.<\/li>\n\n\n\n<li><strong>Does a silent step need to move on before it fails?<\/strong> Yes means your own orchestrator. OTP almost always lands here.<\/li>\n\n\n\n<li><strong>Does any step need a consent check the platform can&#8217;t see?<\/strong> 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&#8217;ve already confirmed on every channel in it.<\/li>\n\n\n\n<li><strong>Do you need each hop as a row in your own database at send time?<\/strong> 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.<\/li>\n\n\n\n<li><strong>None of the above?<\/strong> Configure a platform flow, send to it, and spend your engineering time elsewhere.<\/li>\n<\/ol>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-fallback-ladder-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-fallback-ladder-flow.svg\" alt=\"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.\" class=\"wp-image-3127\"\/><\/a><figcaption class=\"wp-element-caption\">Run each message type through the gates. Whatever answers first decides who owns the ladder.<\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Running both without stepping on each other<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;Both&#8221; works fine as long as each <em>message<\/em> 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 <em>into<\/em> a platform flow and also have your orchestrator watch it and fire its own fallback. You&#8217;d have two systems retrying the same message on two clocks, and the user gets two WhatsApp messages and a phone call.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same rule shows up in the voice API&#8217;s own redial setting. Platform redial and your own retry loop are two owners for one call. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">voice article<\/a> covers that in detail. Here it&#8217;s the reason the voice step below sets redial to zero.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix by Message Type {#}<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s how the gates usually play out. Treat it as a starting point, then run your own message types through the gates.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Message type<\/th><th>Typical ladder<\/th><th>Owner<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Login or signup OTP<\/td><td>SMS -&gt; WhatsApp -&gt; voice<\/td><td>Your orchestrator<\/td><td>Voice step, 30 second deadlines, code expiry<\/td><\/tr><tr><td>Transaction OTP (payments)<\/td><td>SMS -&gt; voice<\/td><td>Your orchestrator<\/td><td>Voice step, tight deadline, strict audit<\/td><\/tr><tr><td>Payment or debit alert<\/td><td>SMS -&gt; WhatsApp<\/td><td>Platform flow, or orchestrator if WhatsApp opt-in is partial<\/td><td>Only failure-driven hops needed<\/td><\/tr><tr><td>Delivery or appointment update<\/td><td>WhatsApp -&gt; SMS<\/td><td>Platform flow<\/td><td>Slow deadline is fine, failure-driven hops suffice<\/td><\/tr><tr><td>RCS-first rich notification<\/td><td>RCS -&gt; SMS<\/td><td>Platform flow<\/td><td>5007 &#8220;RCS Not Enabled&#8221; falls through on empty triggers<\/td><\/tr><tr><td>Account security notice<\/td><td>SMS -&gt; WhatsApp -&gt; Telegram<\/td><td>Your orchestrator<\/td><td>Telegram step<\/td><\/tr><tr><td>Promotional campaign<\/td><td>One channel, no fallback<\/td><td>Neither<\/td><td>Fallback can bill several channels per recipient and adds consent risk<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The last row matches the platform&#8217;s own guidance in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/can-i-use-fallback-for-promotional-bulk-campaigns\/\">Can I use Fallback for promotional bulk campaigns?<\/a>: fallback is for messages where a failed delivery actually hurts.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On RCS-first notifications, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-messaging-api-reference-migration-from-sms\/\">RCS API reference<\/a> explains why <code>globalErrorCode<\/code> 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"which-failures\" class=\"wp-block-heading\">Which Failures Should Move to the Next Channel<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Reach failures: fall back.<\/strong> The channel can&#8217;t get to this person right now, but another one might. RCS <code>globalErrorCode<\/code> 5007 with <code>cause<\/code> &#8220;RCS Not Enabled&#8221; 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Regulatory routing failures: fall back only through a consent check.<\/strong> <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-ncpr_fail-or-dnd-failure-mean\/\"><code>NCPR_FAIL<\/code><\/a> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-dnd_preference_number-mean\/\"><code>DND_PREFERENCE_NUMBER<\/code><\/a> mean the SMS hit the DND registry or the person&#8217;s category preference. That&#8217;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&#8217;t. The platform&#8217;s own example of narrowing trigger codes to &#8220;DND \/ NCPR only&#8221; assumes the next channel is one you&#8217;re allowed to use.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Configuration failures: fall back for OTP, and alert a human.<\/strong> <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-template_check_failed-template-mismatch-mean\/\"><code>TEMPLATE_CHECK_FAILED<\/code><\/a>, <code>SENDERID_MISMATCH<\/code> and <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-invalid_senderid-invalid-senderid-mean\/\"><code>INVALID_SENDERID<\/code><\/a> mean <em>you<\/em> broke something: a template edit, a sender ID that isn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Refusals: stop.<\/strong> The person, your own block list or the regulator has said no. Those get their own section next, because they&#8217;re the ones that cause real damage.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-fallback-trigger-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/10\/diagram-fallback-trigger-table.svg\" alt=\"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.\" class=\"wp-image-3126\"\/><\/a><figcaption class=\"wp-element-caption\">Six outcomes, six different responses. Only reach failures move on without a second thought.<\/figcaption><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"never-fallback\" class=\"wp-block-heading\">Failures That Must Never Trigger Fallback<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">These four codes have something in common: each one is somebody telling you not to send. Switching channels doesn&#8217;t change the answer. It just ignores it somewhere else.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Code<\/th><th>Where it comes from<\/th><th>What it means<\/th><th>Your response<\/th><\/tr><\/thead><tbody><tr><td><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-block_number-blocked-mean\/\"><code>BLOCK_NUMBER<\/code><\/a><\/td><td>SMS, WhatsApp or voice send to a number on your Blocked Numbers list<\/td><td>You (or your team) blocked this number on this product<\/td><td>Stop. Find out why it was blocked before anything else goes out<\/td><\/tr><tr><td><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-black_listed-black-listed-mean\/\"><code>BLACK_LISTED<\/code><\/a><\/td><td>SMS delivery status<\/td><td>The number is on a blacklist the send can&#8217;t pass<\/td><td>Stop and suppress<\/td><\/tr><tr><td><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-does-consent_failed-consent-fail-by-dlt-mean\/\"><code>CONSENT_FAILED<\/code><\/a><\/td><td>DLT consent check on SMS<\/td><td>No valid consent on record for this message<\/td><td>Stop. Collect consent properly, then send<\/td><\/tr><tr><td>131050<\/td><td>WhatsApp, from Meta&#8217;s <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/support\/error-codes\" target=\"_blank\" rel=\"noopener nofollow\">error code list<\/a><\/td><td>The user has stopped marketing messages from your business<\/td><td>Stop marketing on every channel for this person<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;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&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There are two clean fixes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>In a platform flow,<\/strong> select explicit trigger codes for non-OTP flows (reach and timeout failures), so refusals don&#8217;t qualify.<\/li>\n\n\n\n<li><strong>In your own orchestrator,<\/strong> keep a <code>NEVER<\/code> set and check it before anything else. The Python sample in <a href=\"#orchestrator-python\">A Fallback Orchestrator in Python<\/a> does exactly that, and stops with a reason you can log.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Blocked Numbers is also per product. A number blocked for SMS isn&#8217;t automatically blocked for WhatsApp. Your own suppression table should be the authority, as laid out in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">opt-out and suppression guide<\/a>, with the platform list kept in step with it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"consent-gates\" class=\"wp-block-heading\">Consent Gates Between Steps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A fallback ladder crosses channels, and consent doesn&#8217;t travel with it. A customer who gave you their number at checkout may expect a transactional SMS. That doesn&#8217;t mean they opted in to WhatsApp. WhatsApp has its own opt-in expectations, covered in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/whatsapp-messaging-opt-in-guide\/\">WhatsApp opt-in guide<\/a>, and your records need to show you collected one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So before each hop, ask your own data one question: may this person receive <em>this purpose<\/em> on <em>this channel<\/em>? The answer comes from three places:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Suppression:<\/strong> has this person opted out of this channel or of everything? If so, skip.<\/li>\n\n\n\n<li><strong>Consent:<\/strong> do you hold a grant for this purpose and channel that hasn&#8217;t been withdrawn? The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/consent-capture-evidence-sms-whatsapp-rcs-telegram\/\">consent capture guide<\/a> covers the event table.<\/li>\n\n\n\n<li><strong>Reachability:<\/strong> do you even have an identifier for this channel? A Telegram step needs a chat ID, not a phone number.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Turn the answer into an <code>allowed<\/code> set before you call the router, and let the router skip channels that aren&#8217;t in it. Skipping isn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fail closed. If the consent lookup errors or times out, the channel isn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is also why the platform flow fits some audiences and not others. If you only send a flow to recipients who&#8217;ve already passed your gate on every channel in it, the platform&#8217;s &#8220;next step&#8221; and your &#8220;allowed&#8221; agree, and the flow is safe. If your audience is mixed, the gate has to live in your code.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"deadlines\" class=\"wp-block-heading\">Deadlines: Falling Back on Silence<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The quiet failure is the one that hurts. A send is accepted, the delivery report says <code>PENDING<\/code>, and nothing else ever arrives. No failure, so no failure-driven fallback. The user stares at the screen and taps &#8220;resend&#8221;, and now you have two codes in flight.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Give every step a deadline: if there&#8217;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.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Purpose<\/th><th>SMS<\/th><th>WhatsApp<\/th><th>RCS<\/th><th>Voice<\/th><th>Why<\/th><\/tr><\/thead><tbody><tr><td>Login OTP<\/td><td>30 s<\/td><td>30 s<\/td><td>30 s<\/td><td>60 s<\/td><td>A call takes longer to ring and play<\/td><\/tr><tr><td>Transaction OTP<\/td><td>20 s<\/td><td>n\/a<\/td><td>n\/a<\/td><td>60 s<\/td><td>Payment pages time out fast<\/td><\/tr><tr><td>Payment alert<\/td><td>120 s<\/td><td>300 s<\/td><td>300 s<\/td><td>n\/a<\/td><td>Minutes matter, seconds don&#8217;t<\/td><\/tr><tr><td>Appointment reminder<\/td><td>15 min<\/td><td>15 min<\/td><td>15 min<\/td><td>n\/a<\/td><td>Hours of slack before the event<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;t needed. Set it too high and the user gives up first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Add up the ladder against the code&#8217;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&#8217;s validity, the last step delivers a code that&#8217;s already dead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When a deadline fires, the earlier step isn&#8217;t cancelled. It may still deliver. That&#8217;s fine, and it&#8217;s the subject of the next section.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"same-otp\" class=\"wp-block-heading\">The Same OTP on Every Channel<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Picture it going wrong. SMS goes out with 482910 and sits in <code>PENDING<\/code>. 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&#8217;s a fair chance it&#8217;s the one you just invalidated. Support ticket.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;t need to know whether the first step will deliver late. A design that&#8217;s correct either way doesn&#8217;t have to guess.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Keeping one code safe across several channels<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A fair objection, raised in <a href=\"https:\/\/dev.to\/lunarecho\/security-risks-and-improvement-strategies-for-multi-channel-otp-fallback-3mec\" target=\"_blank\" rel=\"noopener nofollow\">this write-up on multi-channel OTP risk<\/a>: the same code on more channels means more places it can leak. That&#8217;s true, and it&#8217;s an argument for discipline, not for fresh codes per channel. Keep these rules:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Sequential only.<\/strong> Never fan the code out to every channel at once &#8220;to be safe&#8221;. Each hop happens because the previous one failed or went quiet.<\/li>\n\n\n\n<li><strong>One validity window.<\/strong> The clock starts when the code is generated, not when each step sends. Fallback never extends it.<\/li>\n\n\n\n<li><strong>One attempt counter.<\/strong> Wrong guesses count against the code, whichever channel it arrived on.<\/li>\n\n\n\n<li><strong>Burn on first success.<\/strong> The moment verification succeeds, the code is dead, and your router stops any step it hasn&#8217;t sent yet.<\/li>\n\n\n\n<li><strong>Never log the plaintext.<\/strong> Store a hash for verification. Delivery rows, ledger rows and support tickets carry message IDs, not codes.<\/li>\n\n\n\n<li><strong>Consent-gated channels only.<\/strong> A code shouldn&#8217;t land in a channel the user didn&#8217;t agree to, which the gate from <a href=\"#consent-gates\">Consent Gates Between Steps<\/a> already covers.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"voice-step\" class=\"wp-block-heading\">Adding Voice OTP as the Last Step<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Voice is the step most teams want and the platform flow doesn&#8217;t offer, so it&#8217;s the most common reason to own the ladder. The call goes through <code>VoiceApi\/send<\/code> with <code>sendMethod=pin<\/code>, which reads a numeric code aloud using one of the pre-recorded intro clips.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n# Last step of an OTP ladder: read the same code aloud.\n# PIN stays a string so a leading zero survives.\nset -euo pipefail\n\nPIN=\"048213\"\n\ncurl -sS -G \"https:\/\/unify.smsgateway.center\/VoiceApi\/send\" \\\n  -H \"apikey: ${SGC_APIKEY}\" \\\n  --data-urlencode \"userid=${SGC_USERID}\" \\\n  --data-urlencode \"sendMethod=pin\" \\\n  --data-urlencode \"mobile=${RECIPIENT}\" \\\n  --data-urlencode \"pin=${PIN}\" \\\n  --data-urlencode \"pinClipId=OneTimePassword\" \\\n  --data-urlencode \"reDial=0\" \\\n  --data-urlencode \"output=json\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Get these four things right and the voice step becomes boring, which is exactly what you want.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Send the PIN as a digit string.<\/strong> The voice API&#8217;s schema types <code>pin<\/code> 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&#8217;s also why the code above is a variable holding text.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Set <code>reDial=0<\/code>.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pick the intro clip on purpose.<\/strong> <code>pinClipId<\/code> values from the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/what-parameters-are-required-to-send-a-voice-otp\/\">voice OTP parameter guide<\/a> include <code>OneTimePassword<\/code>, <code>VerificationCode<\/code>, <code>Default<\/code>, <code>FollowingCode<\/code>, <code>Registration<\/code> and <code>UseThisCode<\/code>. Match it to the screen the user is looking at.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Save <code>transactionId<\/code> as text.<\/strong> The reply is flat: <code>status<\/code>, <code>mobile<\/code>, <code>invalidMobile<\/code>, <code>transactionId<\/code>, <code>statusCode<\/code> and <code>reason<\/code>. Voice IDs are 18 or 19 digits, which a double can&#8217;t hold exactly, so keep the quoted string end to end. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">voice wire contract<\/a> has the full lossless-parsing treatment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"attempt-ledger\" class=\"wp-block-heading\">The Attempt Ledger<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 <em>before<\/em> you call anyone.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two tables are enough.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- One row per logical message, whatever channels it ends up on.\nCREATE TABLE fallback_message (\n    message_id    TEXT        PRIMARY KEY,   -- your ID, generated before any send\n    purpose       TEXT        NOT NULL,      -- 'otp', 'payment_alert', ...\n    recipient     TEXT        NOT NULL,      -- normalised MSISDN\n    otp_hash      TEXT,                      -- NULL for non-OTP purposes\n    created_at    TIMESTAMPTZ NOT NULL,\n    closed_at     TIMESTAMPTZ,\n    close_reason  TEXT                       -- 'delivered', 'expired', 'refused:BLOCK_NUMBER', ...\n);\n\n-- One row per rung of the ladder that was actually tried.\nCREATE TABLE fallback_attempt (\n    message_id    TEXT        NOT NULL REFERENCES fallback_message (message_id),\n    step          INTEGER     NOT NULL,      -- index into the policy ladder\n    channel       TEXT        NOT NULL,      -- 'sms', 'whatsapp', 'rcs', 'voice', 'telegram'\n    provider_id   TEXT,                      -- transactionId \/ uuId, always text\n    state         TEXT        NOT NULL DEFAULT 'reserved',\n    code          TEXT,                      -- normalised failure code\n    reserved_at   TIMESTAMPTZ NOT NULL,\n    sent_at       TIMESTAMPTZ,\n    settled_at    TIMESTAMPTZ,\n    PRIMARY KEY (message_id, step)\n);\n\nCREATE INDEX fallback_attempt_provider ON fallback_attempt (provider_id);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The lifecycle of an attempt row is short:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>reserved.<\/strong> Insert the row. If the insert hits the primary key, someone else already owns this step, so walk away.<\/li>\n\n\n\n<li><strong>pending.<\/strong> The provider accepted the send. Store its ID, as text.<\/li>\n\n\n\n<li><strong>delivered<\/strong> or <strong>failed.<\/strong> A delivery report arrived. Store the normalised code.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">A row stuck in <code>reserved<\/code> means a worker died mid-call. You don&#8217;t know whether the provider got the request. Treat it like a pending step with <code>reserved_at<\/code> as its send time: let its deadline run out, then move to the <em>next<\/em> step. Never resend the same step. A duplicate on the same channel is exactly what the ledger exists to prevent, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-idempotency-preventing-duplicate-sends\/\">idempotency guide<\/a> covers the provider-side half of the same problem.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One more rule: a <code>delivered<\/code> row never goes back to <code>failed<\/code>. Reports can arrive out of order, and a late failure for a step that already delivered is noise. A late <em>delivery<\/em> for a step you&#8217;d marked failed is real, though, so let it upgrade the row.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"orchestrator-python\" class=\"wp-block-heading\">A Fallback Orchestrator in Python<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole router: a policy table, a pure <code>decide()<\/code> function, the ledger writes, and a <code>run_once()<\/code> 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 <code>senders<\/code> for your channel clients.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Call <code>run_once()<\/code> 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.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import sqlite3\nimport time\nfrom dataclasses import dataclass\n\n# One policy per message purpose. Order matters: index 0 is tried first.\nPOLICIES = {\n    \"otp\": {\n        \"ladder\": &#91;\"sms\", \"whatsapp\", \"voice\"],\n        \"deadline_s\": {\"sms\": 30, \"whatsapp\": 30, \"voice\": 60},\n        \"fallback_on\": None,           # None means any failure not in NEVER\n        \"max_age_s\": 300,               # the OTP itself expires after 5 minutes\n    },\n    \"payment_alert\": {\n        \"ladder\": &#91;\"sms\", \"whatsapp\"],\n        \"deadline_s\": {\"sms\": 120, \"whatsapp\": 300},\n        \"fallback_on\": {\"NCPR_FAIL\", \"DND_PREFERENCE_NUMBER\", \"TIMEOUT\"},\n        \"max_age_s\": 3600,\n    },\n}\n\n# Codes that mean \"this person said no\" or \"you are not allowed\".\n# Moving to another channel would ignore that answer.\nNEVER = {\"BLOCK_NUMBER\", \"BLACK_LISTED\", \"CONSENT_FAILED\", \"WA_131050\"}\n\n# Codes that mean you broke something. Fall back, but tell a human.\nCONFIG_FAILURES = {\"TEMPLATE_CHECK_FAILED\", \"SENDERID_MISMATCH\", \"INVALID_SENDERID\"}\n\n@dataclass\nclass Attempt:\n    step: int\n    channel: str\n    sent_at: float\n    state: str          # \"pending\" | \"delivered\" | \"failed\"\n    code: str | None    # normalised failure code, or None\n\ndef decide(purpose, attempts, now, created_at, allowed):\n    \"\"\"Return (\"wait\", None), (\"send\", channel) or (\"stop\", reason).\n\n    attempts: list of Attempt, oldest first.\n    allowed:  set of channels this recipient may receive this purpose on,\n              read from your own consent and suppression tables.\n    \"\"\"\n    policy = POLICIES&#91;purpose]\n    ladder = policy&#91;\"ladder\"]\n\n    if any(a.state == \"delivered\" for a in attempts):\n        return (\"stop\", \"delivered\")\n    if now - created_at &gt; policy&#91;\"max_age_s\"]:\n        return (\"stop\", \"expired\")\n\n    if attempts:\n        last = attempts&#91;-1]\n        if last.state == \"failed\" and last.code in NEVER:\n            return (\"stop\", \"refused:\" + last.code)\n        if last.state == \"pending\":\n            if now - last.sent_at &lt; policy&#91;\"deadline_s\"]&#91;last.channel]:\n                return (\"wait\", None)\n            trigger = \"TIMEOUT\"\n        else:\n            trigger = last.code or \"UNKNOWN_FAILURE\"\n        wanted = policy&#91;\"fallback_on\"]\n        if wanted is not None and trigger not in wanted:\n            return (\"stop\", \"not-a-trigger:\" + trigger)\n        start = last.step + 1\n    else:\n        start = 0\n\n    for step in range(start, len(ladder)):\n        if ladder&#91;step] in allowed:\n            return (\"send\", ladder&#91;step])\n    return (\"stop\", \"ladder-exhausted\")\n\nSCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS fallback_attempt (\n    message_id   TEXT    NOT NULL,\n    step         INTEGER NOT NULL,\n    channel      TEXT    NOT NULL,\n    provider_id  TEXT,\n    state        TEXT    NOT NULL DEFAULT 'reserved',\n    code         TEXT,\n    reserved_at  REAL    NOT NULL,\n    sent_at      REAL,\n    settled_at   REAL,\n    PRIMARY KEY (message_id, step)\n);\n\"\"\"\n\ndef reserve(db, message_id, step, channel, now):\n    \"\"\"Claim a step before calling the provider. False means someone else has it.\"\"\"\n    try:\n        with db:\n            db.execute(\n                \"INSERT INTO fallback_attempt (message_id, step, channel, reserved_at)\"\n                \" VALUES (?, ?, ?, ?)\",\n                (message_id, step, channel, now),\n            )\n        return True\n    except sqlite3.IntegrityError:\n        return False\n\ndef mark_sent(db, message_id, step, provider_id, now):\n    with db:\n        db.execute(\n            \"UPDATE fallback_attempt SET state = 'pending', provider_id = ?, sent_at = ?\"\n            \" WHERE message_id = ? AND step = ? AND state = 'reserved'\",\n            (str(provider_id), now, message_id, step),\n        )\n\ndef settle(db, provider_id, state, code, now):\n    \"\"\"Apply a delivery outcome. A delivered row is never downgraded.\"\"\"\n    with db:\n        db.execute(\n            \"UPDATE fallback_attempt SET state = ?, code = ?, settled_at = ?\"\n            \" WHERE provider_id = ?\"\n            \" AND (state = 'pending' OR (state = 'failed' AND ? = 'delivered'))\",\n            (state, code, now, str(provider_id), state),\n        )\n\ndef run_once(db, msg, now, allowed_for, senders, alert):\n    \"\"\"Advance one message by at most one step. Call it from a timer or after\n    every delivery report for this message.\"\"\"\n    rows = db.execute(\n        \"SELECT step, channel, COALESCE(sent_at, reserved_at), state, code\"\n        \" FROM fallback_attempt WHERE message_id = ? ORDER BY step\",\n        (msg&#91;\"message_id\"],),\n    ).fetchall()\n    attempts = &#91;\n        Attempt(s, ch, t, \"pending\" if st == \"reserved\" else st, code)\n        for s, ch, t, st, code in rows\n    ]\n\n    action, arg = decide(msg&#91;\"purpose\"], attempts, now, msg&#91;\"created_at\"],\n                         allowed_for(msg))\n    if action != \"send\":\n        return action, arg\n\n    channel = arg\n    step = POLICIES&#91;msg&#91;\"purpose\"]]&#91;\"ladder\"].index(channel)\n    if not reserve(db, msg&#91;\"message_id\"], step, channel, now):\n        return \"wait\", None              # another worker owns this step\n\n    if attempts and attempts&#91;-1].code in CONFIG_FAILURES:\n        alert(msg&#91;\"message_id\"], attempts&#91;-1].code)\n\n    provider_id = senders&#91;channel](msg)  # returns the ID as a string\n    mark_sent(db, msg&#91;\"message_id\"], step, provider_id, now)\n    return \"sent\", channel<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What it handles, scenario by scenario (each one was run against an in-memory database with fake senders):<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Scenario<\/th><th>What <code>run_once()<\/code> does<\/th><\/tr><\/thead><tbody><tr><td>First call for a new OTP<\/td><td>Reserves step 0 and sends SMS<\/td><\/tr><tr><td>SMS still <code>PENDING<\/code> at 10 s<\/td><td>Returns wait<\/td><\/tr><tr><td>SMS failed with <code>TEMPLATE_CHECK_FAILED<\/code>, user has no WhatsApp opt-in<\/td><td>Skips WhatsApp, sends voice, raises an alert<\/td><\/tr><tr><td>SMS delivers late while voice is pending<\/td><td>Stops with &#8220;delivered&#8221; and sends nothing more<\/td><\/tr><tr><td>Worker died after reserving SMS, before saving the reply<\/td><td>Waits out the SMS deadline, then sends WhatsApp, never a second SMS<\/td><\/tr><tr><td>SMS fails with <code>BLOCK_NUMBER<\/code><\/td><td>Stops with &#8220;refused:BLOCK_NUMBER&#8221;<\/td><\/tr><tr><td>Payment alert fails with a code that isn&#8217;t in <code>fallback_on<\/code><\/td><td>Stops with &#8220;not-a-trigger&#8221;<\/td><\/tr><tr><td>OTP older than five minutes<\/td><td>Stops with &#8220;expired&#8221;<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Why it&#8217;s built this way:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>decide()<\/code> 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&#8217;s what makes the table above cheap to test, and you should keep it that way as you add rules.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>allowed_for()<\/code> 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&#8217;t catch it and return every channel.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>senders[channel]<\/code> must return the provider&#8217;s ID as a string. For SMS and voice that&#8217;s <code>transactionId<\/code>. Never let a JSON parser turn it into a float on the way. Then key <code>settle()<\/code> on the same string when the delivery report arrives.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Normalise codes before they reach the router. WhatsApp&#8217;s 131050 becomes <code>WA_131050<\/code> so it can&#8217;t collide with a numeric code from another channel. RCS 5007 can become <code>RCS_5007<\/code> the same way.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"reading-outcomes\" class=\"wp-block-heading\">Reading Outcomes Per Channel<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Channel<\/th><th>Where outcomes come from<\/th><th>Fields to read<\/th><th>Watch out for<\/th><\/tr><\/thead><tbody><tr><td>SMS<\/td><td><code>SMSApi\/reports\/status<\/code> with <code>method=getDlr<\/code>, or the account&#8217;s single SMS webhook<\/td><td><code>uuId<\/code>, <code>mobileNo<\/code>, <code>submitTime<\/code>, <code>status<\/code> (<code>DELIVERED<\/code>, <code>FAILED<\/code>, <code>PENDING<\/code>)<\/td><td>Treat status as an open set. Anything unknown maps to pending<\/td><\/tr><tr><td>WhatsApp<\/td><td><code>WAApi\/report<\/code> (POST, <code>wabaNumber<\/code> required)<\/td><td><code>uuId<\/code>, <code>mobileNo<\/code>, status and Meta error code<\/td><td><code>uuId<\/code> and <code>mobileNo<\/code> arrive unquoted, so parse them losslessly<\/td><\/tr><tr><td>RCS<\/td><td><a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/rcs-delivery-report\/\">RCS delivery report<\/a><\/td><td><code>deliveryStatus<\/code>, <code>globalErrorCode<\/code>, <code>cause<\/code><\/td><td>5007 &#8220;RCS Not Enabled&#8221; is a reach failure, not a retry<\/td><\/tr><tr><td>Voice<\/td><td><code>VoiceApi\/report\/dlr<\/code> with <code>tId=true<\/code> and the <code>uuid<\/code><\/td><td><code>status<\/code>, <code>cause<\/code>, <code>globalErrorCode<\/code>, <code>answeredDuration<\/code><\/td><td>&#8220;SUBMITTED&#8221; with &#8220;Waiting For Report&#8221; is still pending<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Two rules apply to all four adapters.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Unknown means pending, not failed.<\/strong> If a status shows up that your adapter doesn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Key on the provider ID you stored.<\/strong> 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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/delivery-report-ingestion-system-of-record\/\">delivery report ingestion guide<\/a> recommends, so a status transition never overwrites another.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"fallback-cost\" class=\"wp-block-heading\">Cost: Every Hop Is a Billed Send<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Fallback isn&#8217;t free, and it&#8217;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&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The expected cost per logical message is the first step&#8217;s price plus each later step&#8217;s price weighted by how often you reach it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>expected_cost = c1 + p1 * c2 + p1 * p2 * c3 + ...\n  c_n = price of step n\n  p_n = share of messages that fall past step n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A worked example with the per-message rates listed for the smallest packs: OTP SMS at INR 0.17 on the <a href=\"https:\/\/www.smsgatewaycenter.com\/otp-sms-pricing\/\">OTP SMS pricing page<\/a> and RCS at INR 0.15 on the <a href=\"https:\/\/www.smsgatewaycenter.com\/rcs-pricing\/\">RCS pricing page<\/a>, 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:<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Ladder<\/th><th>Expected cost per message<\/th><th>Extra spend per 10,000 messages<\/th><\/tr><\/thead><tbody><tr><td>SMS only<\/td><td>INR 0.17<\/td><td>none<\/td><\/tr><tr><td>SMS -&gt; RCS at a 10% hop rate<\/td><td>INR 0.17 + 0.10 x 0.15 = INR 0.185<\/td><td>1,000 RCS sends x INR 0.15 = INR 150<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two cost traps to design out:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Deadlines that are too short.<\/strong> Every premature timeout is a hop you paid for and didn&#8217;t need, plus a duplicate the user sees. Tune deadlines from measured delivery times.<\/li>\n\n\n\n<li><strong>Broken first steps.<\/strong> A template or sender ID fault sends 100% of messages down the ladder. That&#8217;s why configuration failures raise an alert in the orchestrator above. A hop rate that jumps overnight is almost always one of these.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">For a channel whose per-message price you haven&#8217;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 <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/\">invoice reconciliation guide<\/a>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"measuring-fallback\" class=\"wp-block-heading\">Measuring Fallback: Hop Rate and Time to Deliver<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The platform&#8217;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:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT\n    m.purpose,\n    a.step,\n    a.channel,\n    COUNT(*)                                           AS attempts,\n    SUM(CASE WHEN a.state = 'delivered' THEN 1 ELSE 0 END) AS delivered,\n    ROUND(1.0 * SUM(CASE WHEN a.state = 'delivered' THEN 1 ELSE 0 END)\n          \/ COUNT(*), 3)                               AS delivered_share\nFROM fallback_attempt a\nJOIN fallback_message m ON m.message_id = a.message_id\nWHERE m.created_at &gt;= :since\nGROUP BY m.purpose, a.step, a.channel\nORDER BY m.purpose, a.step;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Track these alongside it:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Time to deliver, p50 and p95, from the first send.<\/strong> This is what the user feels. A ladder that delivers 99.5% but at a p95 of 90 seconds is losing logins.<\/li>\n\n\n\n<li><strong>Hop reason mix.<\/strong> Timeout versus reach failure versus configuration failure. A rise in configuration failures is a bug, not a channel problem.<\/li>\n\n\n\n<li><strong>Refusal stops.<\/strong> Messages stopped on <code>BLOCK_NUMBER<\/code>, <code>BLACK_LISTED<\/code>, <code>CONSENT_FAILED<\/code> or 131050. These should be rare and they should never be zero-cost surprises in a campaign.<\/li>\n\n\n\n<li><strong>Double deliveries.<\/strong> Messages with more than one delivered attempt. Some are expected, since that&#8217;s what a deadline does. A sharp rise means a deadline is set too short.<\/li>\n\n\n\n<li><strong>Cost per delivered message.<\/strong> Total spend across all attempts divided by messages that reached someone.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">These fit into the pipeline metrics in the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/observability-for-messaging-pipelines\/\">observability guide<\/a>. 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"rollout-checklist\" class=\"wp-block-heading\">Rollout Checklist<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Work through this in order. Each item is cheap on its own and expensive to skip.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>List every message type and run it through the five gates in <a href=\"#gated-decision\">The Gated Decision<\/a>. Write the owner next to each one.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>For platform flows used for anything other than OTP: select explicit trigger codes, so refusals don&#8217;t move on.<\/li>\n\n\n\n<li>Prepare an approved template per channel per ladder. Keep the template keys in your own registry, as the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/message-template-management-four-channels\/\">template management guide<\/a> suggests.<\/li>\n\n\n\n<li>Map every variable from the first step, and make sure the OTP is mapped from source, never from static text.<\/li>\n\n\n\n<li>Store each platform flow&#8217;s exact name in config and reference it from one place in your code.<\/li>\n\n\n\n<li>For your orchestrator: create <code>fallback_message<\/code> and <code>fallback_attempt<\/code> with the primary key on (message, step).<\/li>\n\n\n\n<li>Write the <code>allowed_for()<\/code> consent gate against your suppression and consent tables, and make it fail closed.<\/li>\n\n\n\n<li>Set per-channel deadlines and check that the ladder total fits inside the OTP&#8217;s validity.<\/li>\n\n\n\n<li>Add the <code>NEVER<\/code> set and the configuration-failure alert.<\/li>\n\n\n\n<li>Set <code>reDial=0<\/code> on voice OTP calls and send <code>pin<\/code> as a string.<\/li>\n\n\n\n<li>Build one delivery adapter per channel that maps unknown statuses to pending.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>Ship the hop rate, time-to-deliver and double-delivery metrics before you send real traffic.<\/li>\n\n\n\n<li>Review the metrics after the first week and adjust deadlines and ladder order.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"common-mistakes\" class=\"wp-block-heading\">Common Mistakes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Falling back on everything for every purpose.<\/strong> 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&#8217;t just ask for the message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Generating a new OTP per channel.<\/strong> The late first message then carries a dead code. One value, one window, one counter.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two retry owners.<\/strong> A platform flow plus your own watcher on the same message, or your orchestrator plus voice redial. Every message gets exactly one owner.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No deadline.<\/strong> Waiting for a failure that never comes. The OTP expires while the ladder is still on step one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Deadlines longer than the code.<\/strong> A voice call that lands after the code has expired is worse than no call, because the user now types a code that&#8217;s rejected.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Resending the same step after a crash.<\/strong> A <code>reserved<\/code> row with no provider ID means &#8220;unknown&#8221;, not &#8220;not sent&#8221;. Move to the next step when the deadline passes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Treating unknown statuses as failures.<\/strong> Every new status string becomes an instant extra send. Unknown should mean pending.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Storing transaction IDs as numbers.<\/strong> Eighteen and nineteen-digit IDs don&#8217;t survive a double. Your <code>settle()<\/code> never matches and every message looks pending forever.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Skipping the consent check because &#8220;it&#8217;s only a fallback&#8221;.<\/strong> A fallback message is still a message on that channel. It needs the same permission as a first send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Using fallback for promotions.<\/strong> It can bill several channels per recipient and pushes marketing into channels where the person never opted in.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A few behaviours that matter for fallback aren&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Send the same code on every step, so a late delivery never matters.<\/strong> Nobody can promise you that a step which missed its deadline won&#8217;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&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Give every step a deadline of your own.<\/strong> How long a platform flow waits on a step that stays pending before it moves on isn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Prove a flow&#8217;s name binding with one test send before production.<\/strong> &#8220;Use the flow name in your API&#8221; 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Keep refusals out of every non-OTP ladder with explicit trigger codes or a <code>NEVER<\/code> set.<\/strong> Whether an empty trigger list treats <code>BLOCK_NUMBER<\/code> or <code>BLACK_LISTED<\/code> as a failure that moves on, you don&#8217;t have to find out. Explicit trigger codes on platform flows and a <code>NEVER<\/code> check in your own router stop refusals either way.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Budget every attempt as billed, including the ones that fail.<\/strong> SMS is charged when sent and isn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Map statuses you don&#8217;t recognise to pending and let the deadline decide.<\/strong> Status lists across four channels aren&#8217;t closed, and new values appear. Pending plus a deadline gives a delayed hop for an unknown value, never an instant duplicate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Never resend a step whose outcome you can&#8217;t see. Move to the next one.<\/strong> If a worker died between calling the provider and saving the reply, you don&#8217;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&#8217;t.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Let a late delivery upgrade an attempt, and never let a late failure downgrade one.<\/strong> Reports from different channels, and sometimes from one channel, can arrive out of order. With that one-way rule in <code>settle()<\/code>, the order they arrive in stops mattering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Check your own consent tables before every hop, and send platform flows only to audiences that already pass every step.<\/strong> 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Keep voice redial off on OTP steps and own the retry yourself.<\/strong> With <code>reDial=0<\/code>, the question of exactly when a platform redial would fire never comes up, and a dead code is never read aloud.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Roll out flow changes as a new flow name, not an edit to a live one.<\/strong> What an edit or a disable does to messages already partway down a ladder isn&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Twelve. Keep provider IDs as strings from the send reply to the delivery adapter.<\/strong> Some replies quote 19-digit IDs and some don&#8217;t. A lossless parse and a text column match either way, while a double silently breaks the join on half of them.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Does the platform&#8217;s Multi-Channel Fallback support voice calls?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No. Platform flows cover SMS, WhatsApp Business API and RCS. Voice, Telegram and email aren&#8217;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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I leave trigger error codes empty?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;t get the message on the next channel.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What happens if the SMS arrives after the WhatsApp message already went out?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The user gets the same message twice. If you sent the same OTP value on both steps, that&#8217;s harmless, because either message carries the working code. That&#8217;s why you generate the code once and never per channel.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How long should I wait before falling back?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;s validity.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is it safe to send the same OTP on several channels?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;t fan it out to every channel at once.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need a separate template for each channel?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is a failed SMS step still charged?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. The per-SMS rate comes out of your wallet when the message is sent, and credits aren&#8217;t refunded once it&#8217;s submitted to the operator. A fallback after a failed SMS is a second paid send on top of the first.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use the platform flow and my own orchestrator together?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use fallback for promotional campaigns?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Build a fallback ladder that never double-sends.<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Try SMS, WhatsApp, RCS and voice OTP side by side in the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">free sandbox<\/a>, read the <a href=\"https:\/\/www.smsgatewaycenter.com\/multi-channel-fallback\/\">Multi-Channel Fallback overview<\/a> to see what the platform flow handles for you, or <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to the team<\/a> about enabling failover on your account.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">More from our blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/reconciling-messaging-invoice-four-channels\/\">Reconciling a Messaging Invoice Across Four Channels: Credits, Currency and the Rate Plan API<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">WhatsApp Business API Wire Contract: Ten Endpoints, Two Base Paths, Five Envelopes<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/telegram-messaging-api-chat-id-model\/\">Telegram Messaging API: Eight Endpoints, the Chat ID Model, and the Error Shape That Breaks Typed Clients<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/messaging-data-retention-erasure-sms-whatsapp-rcs-telegram\/\">Data Retention and Erasure for SMS, WhatsApp, RCS and Telegram: Deleting a Person Without Breaking Opt-Outs<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>When should a failed SMS move to WhatsApp, RCS or a voice call, and who should make that call: the platform&#8217;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.<\/p>\n","protected":false},"author":118,"featured_media":3125,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2016,2373,2031,2028,1177,2371,2370,2374,2372],"class_list":["post-3124","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-delivery-reports","tag-message-orchestration","tag-messaging-architecture","tag-multi-channel-fallback","tag-otp-delivery","tag-rcs-fallback","tag-sms-failover","tag-voice-otp","tag-whatsapp-fallback"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3124","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=3124"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3124\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3125"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3124"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3124"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3124"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}