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

# API recipe: voice broadcast with SMS fallback

> One samples page for the voice → SMS cascade: POST the /notify waterfall with a fallback chain, read the per-hop DLR receipt, track the cascade by group id, prove failed-primary and passed-fallback in the sandbox with magic numbers, and page the hop messages from the SDKs. Every sample in cURL, Node, and Python.

# API recipe: voice broadcast with SMS fallback

This page is the sample pack for the voice → SMS cascade: one POST that leads with a voice call and catches the undelivered cohort on SMS, the receipts that show each hop's outcome, the sandbox run that proves the advance before real carriers are involved, and the SDK calls that page the hop messages. It condenses the [voice broadcast with SMS fallback walkthrough](/guides/voice-broadcast-with-sms-fallback) and the [outbound broadcast setup guide](/guides/outbound-voice-broadcast-with-sms-fallback-setup) into copy-paste request/response pairs.

Use this page when you want the raw HTTP shapes. For the why (why voice never rides a messaging cascade policy, TCPA and DNC gates, the Inbox reply loop), read the [walkthrough](/guides/voice-broadcast-with-sms-fallback). For the concept of tracking a cascade hop by hop, read [track a cascade](/guides/track-a-cascade).

## 1. One-path voice → SMS cascade receipts

### The chain body

The surface that starts on voice is `POST /api/v1/notify` in `mode: "waterfall"`: the recipient crosses two `bindings[]`, binding order is the chain order, and hop 0 fires immediately. The messaging cascade policy (`PUT /api/v1/messages/cascade/policy`) carries an ordered `channels` array for SMS → WhatsApp → RCS style chains, but the resolver filters `voice` out of every stamped chain, so a voice-first cascade is always a `/notify` waterfall:

```json theme={null}
{
  "enabled": true,
  "channels": ["sms", "whatsapp"]
}
```

That policy applies to messaging sends that omit a per-send chain. It cannot carry the voice hop you see below; keep the two surfaces separate in your head: `cascade_policy` is the org default for messaging channels, the waterfall `bindings[]` is the explicit per-recipient chain that may lead with voice.

The waterfall body — voice is hop 0 (fires now), SMS is hop 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": "outage-notices-2026-10" },
    "bindings": [
      {
        "channel": "voice",
        "address": "+14155552671",
        "body": "This is Acme Cloud. Your dashboard outage has been resolved. All services are operating normally. No action is needed.",
        "max_price": 0.25
      },
      {
        "channel": "sms",
        "address": "+14155552671",
        "body": "Acme Cloud: the dashboard outage is resolved; all services normal. Reply STOP to opt out.",
        "max_price": 0.02
      }
    ],
    "max_total_price": 0.27
  }'
```

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

### The failed hop's DLR, then the second hop

Poll the receipt until the voice hop terminates. A `no-answer` inside the window fires the SMS hop; the receipt then carries both hops, each with its own status, price, and error code:

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

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

Hop 0 failed and hop 1 landed: `status` names the cascade outcome, `delivered_channel` names which hop landed it. Only attempted hops bill; `cost.total` is the true cost of reaching this recipient.

### cURL, Node, Python

```bash theme={null}
# Send the waterfall (cURL)
curl -X POST "https://api.orbit.devotel.io/api/v1/notify" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d @waterfall.json
```

```javascript theme={null}
// Send the waterfall (Node)
const res = await fetch("https://api.orbit.devotel.io/api/v1/notify", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.ORBIT_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    mode: "waterfall",
    fallback_window_seconds: 600,
    from: "+15550123",
    metadata: { campaign: "outage-notices-2026-10" },
    bindings: [
      {
        channel: "voice",
        address: "+14155552671",
        body: "This is Acme Cloud. Your dashboard outage has been resolved. All services are operating normally. No action is needed.",
        max_price: 0.25,
      },
      {
        channel: "sms",
        address: "+14155552671",
        body: "Acme Cloud: the dashboard outage is resolved; all services normal. Reply STOP to opt out.",
        max_price: 0.02,
      },
    ],
    max_total_price: 0.27,
  }),
});
const { data } = await res.json();
console.log(data.notify_id, data.active.channel, data.fallback_chain);
```

```python theme={null}
# Send the waterfall (Python)
import os, httpx

res = httpx.post(
    "https://api.orbit.devotel.io/api/v1/notify",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    json={
        "mode": "waterfall",
        "fallback_window_seconds": 600,
        "from": "+15550123",
        "metadata": {"campaign": "outage-notices-2026-10"},
        "bindings": [
            {
                "channel": "voice",
                "address": "+14155552671",
                "body": "This is Acme Cloud. Your dashboard outage has been resolved. All services are operating normally. No action is needed.",
                "max_price": 0.25,
            },
            {
                "channel": "sms",
                "address": "+14155552671",
                "body": "Acme Cloud: the dashboard outage is resolved; all services normal. Reply STOP to opt out.",
                "max_price": 0.02,
            },
        ],
        "max_total_price": 0.27,
    },
)
data = res.json()["data"]
print(data["notify_id"], data["active"]["channel"], data["fallback_chain"])
```

A one-binding waterfall is a 422 (`waterfall mode requires at least 2 bindings`): a chain with one hop has nothing to fall back to. `mode: "fanout"` fires both bindings at once and is the wrong shape for a fallback.

## 2. Track the cascade

Two GETs cover the routine. `GET /api/v1/notify/:notifyId` returns the envelope above; `GET /api/v1/messages/cascade/groups/:groupId` resolves every leg of one logical cascade from the message rows, ordered primary → newest hop, for sends that carry a `message_group_id`:

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

```json theme={null}
{
  "data": {
    "group_id": "cgrp_4t8qw1",
    "legs": [
      {
        "id": "msg_voice11",
        "channel": "voice",
        "to": "+14155552671",
        "status": "failed",
        "sent_at": "2026-10-05T15:00:11Z",
        "fallback_of": null,
        "fallback_reason": null
      },
      {
        "id": "msg_sms12",
        "channel": "sms",
        "to": "+14155552671",
        "status": "delivered",
        "sent_at": "2026-10-05T15:00:47Z",
        "fallback_of": "msg_voice11",
        "fallback_reason": "VOICE_NO_ANSWER"
      }
    ],
    "leg_count": 2
  }
}
```

Per-hop outcomes, read across the two receipts:

| Hop | Channel | Terminal status | Meaning |
| - | - | - | - |
| 0 | voice | `failed` + `VOICE_NO_ANSWER` | The call rang out inside the window; the escalation armed and fired. |
| 0 | voice | `delivered` | The call was answered; the chain ends, no SMS spends. |
| 0 | voice | skipped at POST (`skipped[]`) | Synchronous rejection (opt-out, unprovisioned sender); the chain advanced in-band before any DLR. |
| 1 | sms | `delivered` | The fallback landed; `delivered_channel: "sms"` on the envelope. |
| 1 | sms | `failed` + `RECIPIENT_OPTED_OUT` | The recipient opted out between hops; the cascade reports `failed`. |

Poll `GET /api/v1/notify/:notifyId` on a backoff (5s, 15s, 60s) until `status` leaves `in_progress`; a cascade reports `failed` only when every hop terminated non-delivered and no tail is armed. The dashboard's Track a cascade panel resolves the same envelope; [track a cascade](/guides/track-a-cascade) covers every hop column.

```javascript theme={null}
// Poll the cascade receipt (Node)
const receipt = async (notifyId) =>
  (
    await fetch(`https://api.orbit.devotel.io/api/v1/notify/${notifyId}`, {
      headers: { "X-API-Key": process.env.ORBIT_API_KEY },
    })
  ).json();

let body;
for (const wait of [5_000, 15_000, 60_000]) {
  body = await receipt("nfy_7c2xm9");
  if (body.data.status !== "in_progress") break;
  await new Promise((r) => setTimeout(r, wait));
}
for (const hop of body.data.hops) {
  console.log(hop.hop, hop.channel, hop.status, hop.error_code ?? "");
}
```

```python theme={null}
# Poll the cascade receipt (Python)
import os, time, httpx

for wait in (5, 15, 60):
    body = httpx.get(
        "https://api.orbit.devotel.io/api/v1/notify/nfy_7c2xm9",
        headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    ).json()
    if body["data"]["status"] != "in_progress":
        break
    time.sleep(wait)

for hop in body["data"]["hops"]:
    print(hop["hop"], hop["channel"], hop["status"], hop["error_code"] or "")
```

## 3. Sandbox verify: failed primary, passed fallback

A sandbox key (`dv_test_sk_*`) turns each recipient's trailing digit into a deterministic receipt, per the [sandbox magic numbers table](/sandbox/magic-numbers). The two digits that prove this recipe end to end: a voice hop to a `…3` number reports undelivered (the escalation fires), and an SMS hop to a `…2` number reports delivered. Point the voice binding at `…3` and the SMS binding at `…2` and re-send the body from section 1:

```bash theme={null}
# Failed primary + passed fallback, pinned by the sandbox
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": 120,
    "from": "+15550123",
    "bindings": [
      { "channel": "voice", "address": "+14155550103", "body": "Sandbox voice leg." },
      { "channel": "sms", "address": "+14155550102", "body": "Sandbox SMS leg. Reply STOP to opt out." }
    ]
  }'
```

Then assert the receipt statically. Because the digits pin the receipts, the fixture regexp only has to pin the two hop outcomes:

```bash theme={null}
# verify-cascade.sh — static assert over the sandbox receipt
curl -s -X GET "https://api.orbit.devotel.io/api/v1/notify/$NOTIFY_ID" \
  -H "X-API-Key: $ORBIT_SANDBOX_KEY" > receipt.json

# Hop 0 terminally failed with the no-answer code …
grep -Ezo '"hop": 0,.*"error_code": "VOICE_NO_ANSWER"' receipt.json
# … and the fallback hop delivered, naming sms as the landing channel.
grep -E '"delivered_channel": "sms"' receipt.json
grep -Ezo '"hop": 1,.*"delivered": true' receipt.json
```

How the fixture is constructed: each hop's receipt is deterministic in the sandbox, so per-hop assertions are literal regexps over the hop's `hop` index plus the one field that proves the outcome (`error_code` for the failed primary, `delivered` for the passed fallback). `grep -Ezo` lets the pattern span the hop object's newlines; in CI, exit non-zero on a missing match and the sandbox run gates the deploy of your send script. The sandbox simulates receipts only; it never rewrites your chain, so what advances in sandbox advances in production.

```javascript theme={null}
// Same verify in Node (assert over the sandbox receipt)
import assert from "node:assert/strict";

const body = await (
  await fetch(`https://api.orbit.devotel.io/api/v1/notify/${notifyId}`, {
    headers: { "X-API-Key": process.env.ORBIT_SANDBOX_KEY },
  })
).json();
const [primary, fallback] = body.data.hops;
assert.equal(primary.status, "failed");
assert.equal(primary.error_code, "VOICE_NO_ANSWER");
assert.equal(fallback.status, "delivered");
assert.equal(body.data.delivered_channel, "sms");
```

```python theme={null}
# Same verify in Python
import os, httpx

body = httpx.get(
    f"https://api.orbit.devotel.io/api/v1/notify/{notify_id}",
    headers={"X-API-Key": os.environ["ORBIT_SANDBOX_KEY"]},
).json()["data"]
primary, fallback = body["hops"]
assert primary["status"] == "failed" and primary["error_code"] == "VOICE_NO_ANSWER"
assert fallback["status"] == "delivered"
assert body["delivered_channel"] == "sms"
```

## 4. SDK: page the cascade's hop messages

The hop `message_id`s from the receipt are ordinary message rows, so the SDK message list pages them. Filter to the SMS hop and follow the cursor; match rows back to a cascade by the ids the receipt named:

```javascript theme={null}
// Node SDK — page the SMS hop's messages
import Orbit from "@devotel/sdk-node";

const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY });

const hopIds = new Set(["msg_sms12"]); // from GET /notify/:notifyId
let cursor;
do {
  const page = await orbit.messages.list({
    channel: "sms",
    limit: 100,
    cursor,
  });
  for (const m of page.data) {
    if (hopIds.has(m.id)) console.log(m.id, m.status, m.channel);
  }
  cursor = page.next_cursor;
} while (cursor);
```

```python theme={null}
# Python SDK — page the SMS hop's messages
import os
from devotel import Orbit

orbit = Orbit(api_key=os.environ["ORBIT_API_KEY"])

hop_ids = {"msg_sms12"}  # from GET /notify/:notifyId
cursor = None
while True:
    page = orbit.messages.list(channel="sms", limit=100, cursor=cursor)
    for m in page.data:
        if m.id in hop_ids:
            print(m.id, m.status, m.channel)
    if not page.next_cursor:
        break
    cursor = page.next_cursor
```

```bash theme={null}
# cURL equivalent — first page of SMS messages
curl -s "https://api.orbit.devotel.io/api/v1/messages?channel=sms&limit=100" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

The waterfall POST itself is the same JSON in any language; the section 1 Node and Python cards are the SDK-free shape, and the SDK adds typed paging on the read side.

## 5. Troubleshooting

* **SMS fires immediately, voice never rings.** The voice binding rejected synchronously; the POST response `skipped[]` names it with an `error.code` (opt-out, unprovisioned sender, bad from-number). The chain advanced in-band before any DLR.
* **422 `TCPA_FEDERAL_DIALING_WINDOW_BLOCKED`.** The recipient's local time was outside the federal 8 AM–9 PM dialing window at dispatch. Schedule per recipient-local time; the window cannot be disabled, and the SMS hop still catches blocked recipients.
* **`RECIPIENT_OPTED_OUT` on the SMS hop.** The recipient opted out between the voice failure and the escalation. Scrub your tenant do-not-call source before the batch ([DNC pre-flight scrub](/guides/dnc-preflight-scrub)).
* **`dropped_for_cost_cap` names the SMS hop.** `max_total_price` was below the cascade's resolved cost; raise the cap or drop the expensive hop.
* **Receipt still `in_progress` past the window.** A voice receipt that lands after `fallback_window_seconds` does not escalate; the send is too stale. Size the window to the message's urgency.

## See also

* [Voice broadcast with SMS fallback, threaded back into the Inbox](/guides/voice-broadcast-with-sms-fallback) — the full walkthrough: compliance gates and the Inbox reply loop.
* [Outbound voice broadcast with SMS fallback setup](/guides/outbound-voice-broadcast-with-sms-fallback-setup) — the dashboard-side campaign plan.
* [Channel fallback recipe](/guides/fallback-channels-recipe) — the generic two-step and three-step waterfall shapes.
* [Track a cascade](/guides/track-a-cascade) — field-by-field reading of the hop table.
* [Sandbox magic numbers](/sandbox/magic-numbers) — every deterministic receipt digit.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.