> ## 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.

# Voice broadcast with SMS fallback, threaded back into the Inbox

> Compose a voice → SMS waterfall through the notify API: why voice never rides cascade_policy, how to build the two-binding payload with per-step pricing and a fallback window, the TCPA and DNC gates to clear before the voice leg, how to read the per-hop cascade receipt, and how SMS replies land in the Inbox with opt-outs propagating back to suppression.

# Voice broadcast with SMS fallback, threaded back into the Inbox

This walkthrough composes the whole voice → SMS fallback campaign end to end: route the waterfall, build the payload, clear the compliance gates, launch, read the per-hop receipt, and receive SMS replies in the Inbox. It stitches the broadcast side ([voice broadcasts](/guides/voice-broadcasts)), the fallback pattern ([channel fallback recipe](/guides/fallback-channels-recipe)), and the reply loop into one run.

Use this page when you want a voice call to lead and an SMS to catch recipients the call could not reach — appointment reminders, outage notifications, delivery windows — with replies landing where your team answers them. For the dashboard one-shot workflow with no fallback, stay on the [voice broadcast guide](/guides/voice-broadcasts). For OTP-style voice fallback, use a [Verify profile](/guides/verify-fallback-chains) instead of hand-rolling the chain.

## 1. Why `cascade_policy` drops voice — route as a `/notify` waterfall instead

The smart-send resolver filters `voice` (and `fax`) out of every stamped fallback chain — the terminal-DLR escalation hook applies the same eligibility, so `cascade_policy: { fallback_channels: ["sms"] }` can never carry a voice hop on either side of the arrow. The [channel fallback recipe](/guides/fallback-channels-recipe) calls this out for voice → SMS specifically.

The surface that does start on voice is `POST /notify` in `mode: "waterfall"`: you pass the recipient across two or more `bindings[]`, the binding order is the chain order, and **hop 0 fires immediately**. The remaining hops arm as the escalation tail and advance only when the active hop reports a terminal failure inside `fallback_window_seconds`.

Two consequences you plan around:

* **Voice-first means voice is synchronous.** The voice hop queues in the POST; if it rejects synchronously (unprovisioned sender, a per-channel opt-out, a bad from-number), the waterfall advances in-band to SMS before any DLR exists. The POST response names which binding queued in `active` and which it skipped in `skipped[]`.
* **The fallback window bounds staleness.** An undelivered receipt that lands after `fallback_window_seconds` does not escalate — the send is too stale to be useful. Set it per recipient cohort, not once globally: a "your appointment is in 2 hours" reminder warrants 300–900 seconds, an outage notice warrants longer (floor 30s, ceiling 24h; default 24h when omitted).

Everything below is tenant-owned: you pick the channel order, the window, the sender numbers, and the cost ceilings per send. Nothing here flips a platform-wide switch. For per-binding boolean fallback triggers (campaign style) instead of an ordered chain, see the [fallback chains guide](/guides/fallback-chains).

## 2. Compose the broadcast payload — two bindings, one envelope

A two-binding waterfall for one recipient looks like this. Voice is binding 0 (fires first), SMS is binding 1 (armed; fires only on a terminal voice failure inside the window):

```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,
    "from": "+15550123",
    "metadata": { "campaign": "dental-reminders-2026-09" },
    "bindings": [
      {
        "channel": "voice",
        "address": "+14155552671",
        "body": "Hi, this is Acme Dental reminding you of your cleaning on Thursday at 2:30 PM. To confirm, press 1. To reschedule, call us at 555-0123.",
        "max_price": 0.25
      },
      {
        "channel": "sms",
        "address": "+14155552671",
        "body": "Acme Dental: reminder for your cleaning Thu 2:30 PM. Reply YES to confirm or R to reschedule. Reply STOP to opt out.",
        "max_price": 0.02
      }
    ],
    "max_total_price": 0.27
  }'
```

What each field does:

* **`bindings[]`** — the ordered DeliveryChain. Hop 0 (voice) carries the spoken script; hop 1 (SMS) carries a shortened copy restated for text — mirror the purpose and include opt-out phrasing on the SMS hop (`Reply STOP …`), since this may be the recipient's only touch with the message.
* **`fallback_window_seconds: 600`** — per-send staleness window. A voice `no-answer` / `busy` / `failed` receipt inside ten minutes fires the SMS; one that returns eleven minutes later does not.
* **`metadata.campaign`** — a shared billing topic stamped onto every hop's message row, so cost and delivery reporting (`GET /notify/:notifyId`, campaign exports) attribute both hops to the same campaign rather than splitting them across orphaned message ids.
* **`max_price` (per binding)** — per-step cost ceiling in USD. When the resolved cost of that hop would exceed the cap, the hop is rejected before the wallet is billed, so an expensive hop never fires even once.
* **`max_total_price: 0.27`** — cumulative cascade cap across every hop of this waterfall. If the running total would exceed it, the remaining tail is trimmed and reported as `dropped_for_cost_cap`. Fan-out mode ignores it — there is no cascade to cap.

A shared `from` + shared `body` on the envelope apply to any binding that omits its own; per-binding `body`, `from`, `template_name`, and `max_price` win on conflict. In practice the voice and SMS scripts always differ in length and phrasing, so per-binding bodies are the norm in a voice → SMS waterfall.

**Voice clone and the dialing window.** To speak the script in a cloned voice, create the clone first (signed consent + a short sample script — the [voice clones guide](/guides/voice-clones) covers the consent and quality gates), then reference the clone through the voice sender profile the `from` number resolves against. A voice clone changes the spoken rendering, not the compliance footprint: the federal 8 AM–9 PM recipient-local dialing window and your workspace quiet hours still apply to the voice hop, and neither a clone nor a stock voice can relax them.

**Guard: a one-binding waterfall is a 422.**

```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",
    "bindings": [
      { "channel": "voice", "address": "+14155552671", "body": "Solo voice hop." }
    ]
  }'
```

```json theme={null}
HTTP 422
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "waterfall mode requires at least 2 bindings (a primary channel plus one or more fallbacks ordered richest → cheapest)."
  }
}
```

A waterfall with one binding has nothing to fall back to, so the API rejects it outright and arms nothing. Send a plain voice-only notification by omitting `mode` (fan-out of one) — or use the campaigns surface.

**Fan-out vs waterfall for this pattern.** `mode: "fanout"` (the default) fires every binding at the same time — voice and SMS together. For a fallback you want `waterfall`, where the SMS only spends money after voice terminally fails; fan-out is for "alert on every channel now" traffic and burns both hops every time.

## 3. Enforce TCPA and DNC before the voice leg

The voice hops in a waterfall are ordinary outbound calls — every gate the broadcast composer applies applies here, on every recipient in every binding. Clear these before you queue a batch of waterfalls:

**TCPA federal window (non-relaxable).** Every voice hop is stamped against the federal 8 AM–9 PM recipient-local dialing window at dispatch. A recipient whose local time falls outside the window at send refuses with `422 TCPA_FEDERAL_DIALING_WINDOW_BLOCKED`. This is a schedule fix, not a config fix — no workspace setting relaxes it. For a broadcast-sized batch, schedule the launch so the whole recipient cohort stays inside its own local windows, or let blocked recipients land on the SMS hop (the synchronous rejection advances the chain in-band, and the SMS hop refuses only if SMS quiet hours also block it — see the [TCPA posture guide](/guides/tcpa-quiet-hours-and-windows) for how SMS/WhatsApp quiet hours differ from the voice federal guard).

**Workspace quiet hours.** On top of the federal window, your quiet-hours configuration bounds the per-recipient dialing window and is checked at send. Verification of what your quiet hours actually resolve to lives under the compliance settings — run it before the launch, not after the first 422.

**Tenant DNC pre-flight scrub.** Suppressed and opted-out recipients are excluded at send time, but batching is cheaper than per-send rejection: run the tenant do-not-call source against your recipient list **before** launching. A scrubbed-off recipient on the voice hop shows up in the POST response as a synchronous skip with an opt-out code, and on SMS as `RECIPIENT_OPTED_OUT` when it reaches that hop — either way, the cleaner move is keeping them out of the bindings. The [DNC pre-flight scrub guide](/guides/dnc-preflight-scrub) walks the same scrub at single-recipient and batch scale; check the do-not-call source under **Outbound → Audiences** before the first batch of the day.

**Recording-consent acknowledgement.** If your voice hop renders into a recorded call (your number's recording-consent posture), the campaign-style gate is acknowledgement; verify it at the workspace level before the first send.

## 4. Launch, then read the per-hop receipt

Launch with the POST above. The response confirms which binding is live and what is armed:

```json theme={null}
HTTP 200
{
  "data": {
    "notify_id": "nfy_9d4kq2",
    "mode": "waterfall",
    "active": {
      "index": 0,
      "channel": "voice",
      "to": "+14155552671",
      "status": "queued",
      "message_id": "msg_voice01"
    },
    "skipped": [],
    "fallback_chain": [
      { "order": 1, "channel": "sms", "to": "+14155552671" }
    ],
    "fallback_window_seconds": 600,
    "max_total_price": 0.27,
    "dropped_for_cost_cap": []
  }
}
```

A `…/notify/:notifyId` GET aggregates the hop list as DLRs land — one status, one summed price, and per-hop error codes:

```bash theme={null}
curl -X GET "https://api.orbit.devotel.io/api/v1/notify/nfy_9d4kq2" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "notify_id": "nfy_9d4kq2",
    "status": "delivered",
    "hop_count": 2,
    "hops": [
      {
        "hop": 0,
        "message_id": "msg_voice01",
        "channel": "voice",
        "to": "+14155552671",
        "status": "failed",
        "delivered": false,
        "price": 0.18,
        "currency": "USD",
        "error_code": "VOICE_NO_ANSWER",
        "error_message": "Call unanswered after ring timeout",
        "sent_at": "2026-09-20T13:00:11Z",
        "failed_at": "2026-09-20T13:00:46Z"
      },
      {
        "hop": 1,
        "message_id": "msg_sms02",
        "channel": "sms",
        "to": "+14155552671",
        "status": "delivered",
        "delivered": true,
        "price": 0.008,
        "currency": "USD",
        "error_code": null,
        "error_message": null,
        "sent_at": "2026-09-20T13:00:47Z",
        "delivered_at": "2026-09-20T13:00:52Z"
      }
    ],
    "delivered_channel": "sms",
    "delivered_at": "2026-09-20T13:00:52Z",
    "cost": { "total": 0.188, "currency": "USD" },
    "truncated": false,
    "max_total_price": 0.27,
    "dropped_for_cost_cap": []
  }
}
```

How to read it:

* **Hop 0 voice, hop 1 SMS — the full cascade in one envelope.** Only the hops that actually attempted bill: `cost.total` is the true cost of getting this recipient, not the sum of both hops' ceilings. `price` and `error_code` are per-hop, so a `VOICE_NO_ANSWER` on hop 0 does not poison the read on hop 1.
* **`status: "delivered"` names the cascade outcome, `delivered_channel: "sms"` names who landed it.** A cascade reports `failed` only when EVERY hop terminated non-delivered and no escalation tail is armed; `in_progress` while any hop is still live or armed.
* **`skipped[]` at POST time vs `error_code` here.** A binding that rejected synchronously (bad sender, opt-out) never entered the cascade; it is in the POST response's `skipped[]`, with `error.code`. A hop that queued and terminally failed at the carrier shows here with `error_code` on its hop row.
* **`truncated: true`** (only past the hop ceiling) and **`dropped_for_cost_cap`** (only when you set `max_total_price`) — both listed in the cascade receipt when they apply.

The dashboard's **Track a cascade** panel (Messages → Multi-channel notify) resolves the same envelope without the API call — one hop list, per-hop cost, the escalated-past-primary flag. For full field reading, the [track a cascade guide](/guides/track-a-cascade) covers every hop column.

## 5. Run SMS replies into the Inbox — the reply loop closes the campaign

When the SMS hop is the one that lands, that number can reply — and replies need somewhere to go. The sender number on your SMS binding is the Inbox thread identity: replies to it open (or continue) a conversation with the same recipient pair, so your team answers in **Inbox → Conversations** instead of a webhook you build yourself. The [inbox setup guide](/guides/inbox-setup) wires the number into a conversation surface; [inbound SMS routing](/guides/inbound-sms-routing) covers which number answers which kind of traffic.

What propagates back, and where it lands:

* **A YES / R reply is a conversation row.** The recipient's YES confirms the appointment; that row belongs in the same Inbox thread the SMS hop sent from, with the campaign metadata intact, so an agent sees the full context ("Acme Dental reminder — replied YES") without joining message ids. [Keyword auto-reply rules](/guides/keyword-auto-reply-rules) can automate the acknowledgement when the reply is a known intent.
* **A STOP reply is an opt-out, not just a message.** Same thread, but the opt-out also writes to the tenant suppression list immediately — the next binding you build against this recipient is excluded at send time (voice hop skips it synchronously with an opt-out code, SMS hop refuses `RECIPIENT_OPTED_OUT`). The [opt-out rules guide](/guides/opt-out-rules) is the semantics reference; the [consent and suppression model concept](/concepts/consent-and-suppression-model) covers how per-channel opt-outs land.
* **Voice-leg outcomes feed back too.** A recipient who pressed a digit on the voice call (confirm / reschedule) never needed the SMS hop — the chain ended delivered at hop 0. That touch does not create an Inbox thread, but it shares the same `campaign` topic, so reporting unifies both outcomes. A recipient who neither answered the call nor received the SMS inside the window is the genuinely-unreachable cohort — the waterfall is what tells you exactly which recipients those are.

The reply loop is why the voice → SMS waterfall beats a voice-only broadcast for reminders: the SMS hop gives the recipient a channel to answer on, and the Inbox gives your team one queue to answer them in.

## 6. Sandbox validation before production

Prove the advance before real carriers are involved. A sandbox API key (`dv_test_sk_*`) turns the recipient's trailing digit into a deterministic receipt — the [sandbox magic numbers table](/sandbox/magic-numbers) lists every scenario. For this walkthrough, the two scenarios that prove the whole flow are: `…3` (undelivered primary → escalation fires) on the voice hop and `…2` (delivered) on the SMS hop. Re-run section 2 with a one-binding waterfall to confirm the 422 guard. The sandbox simulates receipts; it never rewrites your chain — what advances in sandbox advances in production.

## 7. Troubleshooting

* **Voice hop never fires; SMS is immediate.** Check the POST response `skipped[]` — a synchronous rejection (opt-out, unprovisioned sender, bad from-number) advances the chain in-band before any DLR exists.
* **422 `TCPA_FEDERAL_DIALING_WINDOW_BLOCKED` on some recipients.** Their local time was outside the federal window at dispatch. Schedule per recipient-local time; the window cannot be disabled. The SMS hop is unaffected and still catches them.
* **`RECIPIENT_OPTED_OUT` on the SMS hop.** The recipient opted out of SMS between the voice failure and the escalation firing, or you did not scrub DNC pre-flight. Verify the tenant DNC source and re-run the pre-flight scrub on the next batch.
* **`dropped_for_cost_cap` names a channel you wanted.** Your `max_total_price` was lower than the cascade's resolved cost; raise the cumulative cap or drop the expensive hop.
* **SMS replies not threading into Inbox.** Verify the sender number is an inbox-routed number ([inbound SMS routing](/guides/inbound-sms-routing)); a number with no inbox route keeps replies on webhooks only.
* **The voice hop reports `delivered` but you expected SMS.** An answered call terminates the chain — no SMS fires. That is the waterfall working as designed; the recipient got the message by voice.

## See also

* [Channel fallback recipe](/guides/fallback-channels-recipe) — the generic two-step and three-step waterfall shapes, sandbox-tested.
* [Fallback chains](/guides/fallback-chains) — every fallback surface compared (campaign ladders, smart-send policy, Verify).
* [Voice broadcasts](/guides/voice-broadcasts) — the dashboard one-shot broadcast this walkthrough extends.
* [Track a cascade](/guides/track-a-cascade) — field-by-field reading of the notify receipt.
* [TCPA posture](/guides/tcpa-quiet-hours-and-windows) and [DNC pre-flight scrub](/guides/dnc-preflight-scrub) — the pre-launch gates.
* [Inbox setup](/guides/inbox-setup) and [Opt-out rules](/guides/opt-out-rules) — the reply loop and suppression side.
