> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Channel fallback recipe: build a two-step fallback chain you can test in sandbox

> One end-to-end recipe for a primary → fallback channel chain: where fallback chains live (smart-send cascade_policy and notify waterfall bindings), two-step payloads for WhatsApp→SMS, email→SMS, and voice→SMS, a three-step cascade, sandbox validation with magic numbers, and failure reading with track-a-cascade.

# Channel fallback recipe: build a two-step fallback chain

This recipe walks one fallback chain from setup to verified delivery: pick the surface, build a two-step primary → fallback chain, extend it to three hops when you need a final catch-all, validate the whole path in sandbox with magic numbers, and read the outcome — including the carriers that never participated — from the cascade receipt.

Every control below is tenant-owned: you choose the channels, the order, the escalation window, and the cost caps per send. Nothing here flips a platform-wide switch.

## 1. Where a fallback chain is set

Two send-time surfaces arm a chain, and they compose differently:

* **Send-time smart route — `cascade_policy`** on `POST /messages/smart-send` (or read-only on `POST /messages/route-preview`). The smart router picks the primary channel from your capability flags and message shape; the policy shapes only the fallback set, which is resolved and stamped onto the message as `metadata.fallback_channels`. A terminal delivery failure on the primary then cascades to the next channel in the list. Messaging channels only — `voice` and `fax` are filtered out of the stamped chain.
* **Recipients-and-chain bindings — `mode: "waterfall"`** on `POST /notify`. You pass one address across two or more `bindings[]` rows; the binding order **is** the chain order (the DeliveryChain shape). Only the first binding fires. The rest arm as an ordered fallback chain, and the engine advances when the active hop reports a terminal failure inside `fallback_window_seconds`.

Rule of thumb: use the smart-send policy when you want the router to choose the primary and you only need to pin the fallback order; use waterfall bindings when you want to control the primary yourself. The [fallback chains guide](/guides/fallback-chains) compares both with campaign `channels[]` ladders and Verify profiles.

## 2. The two-step recipe: primary → fallback

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

### WhatsApp → SMS (transactional)

The most common production chain: rich primary, universal fallback. The primary is router-picked; the fallback order is pinned.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/messages/smart-send" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "body": "Your order #8421 shipped — track it here.",
    "message_type": "transactional",
    "whatsapp_available": true,
    "cascade_policy": {
      "fallback_channels": ["sms"]
    }
  }'
```

If the WhatsApp primary comes back `undelivered` or `failed` on its terminal receipt, the escalation hook fires a new send on `sms` carrying the same body. If WhatsApp delivers, the chain ends — SMS never fires.

### Email → SMS (one recipient, both channels)

For a notify-style send where you hold both an email address and a phone number for one recipient, enter the same recipient across two bindings so the rows merge into one chain. The email hop is cheapest and fires first; SMS is the safety net.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/notify/" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "waterfall",
    "fallback_window_seconds": 300,
    "body": "Your statement is ready.",
    "bindings": [
      { "channel": "email", "address": "sam@customer.example" },
      { "channel": "sms",   "address": "+14155552671" }
    ]
  }'
```

Response — 200, first hop active, one armed fallback:

```json theme={null}
{
  "data": {
    "notify_id": "nfy_2step1",
    "mode": "waterfall",
    "active": { "index": 0, "channel": "email", "to": "sam@customer.example", "status": "queued", "message_id": "msg_e01k5z" },
    "skipped": [],
    "fallback_chain": [
      { "order": 1, "channel": "sms", "to": "+14155552671" }
    ],
    "fallback_window_seconds": 300
  }
}
```

Keep the window short for time-critical sends — a 300-second window bound the escalation, so a late email receipt cannot trigger the SMS hop hours later.

### Voice → SMS (voice-primary flows)

Voice is dropped from stamped fallback chains everywhere — the smart-send resolver filters it out, and the terminal-DLR escalation hook applies the same eligibility. So a chain that starts on voice and falls back to SMS has to put voice at the **front of the binding order** on `POST /notify`, where hop 0 fires immediately:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/notify/" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "waterfall",
    "fallback_window_seconds": 180,
    "body": "Your access code is 492 118.",
    "bindings": [
      { "channel": "voice", "address": "+14155552671" },
      { "channel": "sms",   "address": "+14155552671" }
    ]
  }'
```

For OTP traffic specifically, prefer a [Verify profile](/guides/verify-fallback-chains) over a hand-rolled notify chain: its step-up engine (`sms → whatsapp → voice`, say) re-delivers the same code per hop with a `templates.voice` script, handles per-hop timeouts, and fires `verification.fallback_exhausted` when every hop fails — none of which you would have to build.

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

Add a third hop when the first two are both phone-centric and you need an off-phone catch-all. SMS first (universal, immediate), voice second (gets a ringing phone, not just a notification badge), email last (the permanent record that lands even when the handset is unreachable):

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/notify/" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "waterfall",
    "fallback_window_seconds": 600,
    "body": "Security alert: a new sign-in to your account.",
    "bindings": [
      { "channel": "sms",   "address": "+14155552671" },
      { "channel": "voice", "address": "+14155552671" },
      { "channel": "email", "address": "sam@customer.example" }
    ]
  }'
```

Only the SMS hop fires on this call. A terminal failure advances the chain to voice, then to email; `GET /notify/:notifyId` later shows every attempted hop with its own cost.

**`fallback: true` vs a fallback chain.** On campaign-style sends you declare a chain per entry with `fallback_on` triggers (`failed`, `no_delivery`, `no_engagement`) — a per-hop boolean "this hop may fall back" rather than a chain. Use that trigger style when each entry should independently declare the condition under which the ladder advances (the [fallback chains guide](/guides/fallback-chains) covers the trigger semantics). Use the notify `waterfall` shape (or never set a trigger at all) when the chain is a single ordered list you control end to end. If your send carries no fallback declaration at all, a terminal failure on the primary is final.

## 4. Validate it in sandbox with magic numbers

Sandbox turns the trailing digit of the recipient into a deterministic receipt scenario, so you can prove the chain advances before it touches a real carrier. Use a sandbox API key (`dv_test_sk_*` prefix) — the same key shape the [sandbox quickstart](/sandbox/overview) issues.

1. **Fire the primary against a "always fails" number.** Trailing digit `3` returns `sent → undelivered` within about a second:

   ```bash theme={null}
   curl -X POST "https://api.orbit.devotel.io/api/v1/notify/" \
     -H "X-API-Key: $ORBIT_SANDBOX_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "mode": "waterfall",
       "fallback_window_seconds": 300,
       "body": "Sandbox fallback test.",
       "bindings": [
         { "channel": "sms", "address": "+15005550003" },
         { "channel": "sms", "address": "+15005550002" }
       ]
     }'
   ```

   Hop 0 targets a failing number (`…003` → `undelivered`), hop 1 targets a delivering number (`…002` → `delivered`). Save the `notify_id` from the response.

2. **Confirm the chain advanced.** Your sandbox webhook endpoint receives the hop-0 terminal receipt, then the hop-1 send — exactly the advance sequence a real carrier produces. The full digit-to-scenario table is at [Sandbox magic numbers](/sandbox/magic-numbers).

3. **Read the receipt.** Resolve the envelope and assert `delivered_channel` names the fallback hop:

   ```bash theme={null}
   curl -X GET "https://api.orbit.devotel.io/api/v1/notify/nfy_sandboxTest1" \
     -H "Authorization: Bearer $ORBIT_SANDBOX_KEY"
   ```

   You want `hop 0` `failed` (or `undelivered`), `hop 1` `delivered`, and `delivered_channel: "sms"`. If hop 1 still shows `in_progress`, the window has not closed — poll again.

4. **Negative test the guard.** Re-send step 1 with a single binding. The API rejects a one-binding waterfall with `422` and nothing arms — the same guard the composer applies per recipient.

When the sandbox receipt shows the advance you expect, the same payload works in production — sandbox only simulates the receipts, it never rewrites your chain.

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

`GET /notify/:notifyId` returns every hop the chain attempted, each with its own `error_code`, `error_message`, and billed `price`. That per-hop read is how you find a carrier that never participated:

* **A hop that never went out** — rejected before it armed, or skipped. The receipt's `skipped` list (at send time) and per-hop `failed` entries tell you which binding was rejected and why — for example `RECIPIENT_OPTED_OUT` on a channel the recipient unsubscribed from. The chain advances past it without spending on that hop.
* **`dropped_for_cost_cap`** — channels the chain declared but never attempted because they would have exceeded your `max_total_price` budget. A non-participating carrier hops here rather than billing a send you capped.
* **`truncated: true`** — the runaway-envelope ceiling hit and the receipt dropped the overflow. The hops you see are the hops that ran.

Resolve the same notify id in the dashboard's **Track a cascade** panel (Messages → Multi-channel notify, below the composer) — one hop list, per-hop cost, the escalated-past-primary flag. The panel and the API read the same envelope; use whichever your runbook prefers. Full field-by-field reading is in [Track a cascade](/guides/track-a-cascade).

## See also

* [Fallback chains](/guides/fallback-chains) — every fallback surface compared: campaign ladders, per-message policy, Verify profiles.
* [Smart-send fallback chains](/guides/smart-send-fallback-chains) — the resolver behind `cascade_policy`.
* [Track a cascade](/guides/track-a-cascade) — read a full envelope from its notify id.
* [Pre-launch readiness for fallback chains](/guides/campaign-fallback-readiness) — probe every step before a campaign launch.
* [Sandbox magic numbers](/sandbox/magic-numbers) — the digit-to-scenario table the sandbox validation above uses.
