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

# Fallback configuration worked samples: notify waterfall and cascade policy

> Copy-pasteable request and response samples for every fallback primitive — the POST /notify waterfall with per-hop and cumulative cost ceilings, the org default cascade policy, the per-send cascade field, and the two read-back endpoints — plus the consent and quiet-hours guardrails every hop re-runs.

# Fallback configuration worked samples

The concept pages define Orbit's fallback planes; this page gives you the exact payloads to arm them. Every sample is a tenant-owned control: the chain order, the escalation window, and the cost ceilings are choices you make per request or per organization. Copy a request as written, substitute your own numbers and key, and compare the envelope you get back.

The two read-back shapes and the five planes they sit on are defined in [The five fallback planes](https://docs.orbit.devotel.io/concepts/fallback-and-cascade-planes.md) and [Cascade failover policy and groups](https://docs.orbit.devotel.io/concepts/message-cascade-groups.md) — read those for precedence rules; come here for the wire shape. The visual composer over `POST /notify` is covered in [Cascade fallback chains in the notify composer](https://docs.orbit.devotel.io/guides/notify-cascade-failover.md).

## The primitives, mapped to fields

Four primitives, each a field you send:

| Primitive | Field | Where |
| - | - | - |
| Ordered fallback chain | `bindings` order (waterfall), `channels` (policy / `cascade`), `fallback_channels` (raw metadata) | `POST /notify`, `PUT /messages/cascade/policy`, any channel send |
| Escalation freshness window | `fallback_window_seconds` | same surfaces; defaults to 24 hours, capped at 30 days on the policy and the typed `cascade` field, and at 24 hours on `POST /notify` |
| Per-hop cost ceiling | `max_price` (shared or per binding) | `POST /notify` — a hop whose resolved price exceeds the ceiling is rejected before billing and the cascade advances |
| Cumulative cascade cap | `max_total_price` | `POST /notify` — hops that would push the total over the cap are trimmed from the tail and named in `dropped_for_cost_cap` |

An escalation trigger is always a **terminal delivery receipt** — `failed`, `undelivered`, or `rejected` — arriving inside the window. There is no timed retry without a receipt, and each hop runs at most once: a receipt replayed by the carrier cannot re-send a hop.

## Worked sample 1: SMS backup after a WhatsApp primary

One recipient, one message, two hops. Order the bindings richest → cheapest; the first binding is the primary, the rest form the escalation tail.

```bash cURL 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",
    "body": "Your appointment is tomorrow at 10:30.",
    "fallback_window_seconds": 3600,
    "bindings": [
      { "channel": "whatsapp", "address": "+14155552671" },
      { "channel": "sms",      "address": "+14155552671" }
    ]
  }'
```

```typescript Node.js theme={null}
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",
    body: "Your appointment is tomorrow at 10:30.",
    fallback_window_seconds: 3600,
    bindings: [
      { channel: "whatsapp", address: "+14155552671" },
      { channel: "sms", address: "+14155552671" },
    ],
  }),
});

const { data } = (await res.json()) as {
  data: { notify_id: string; results: Array<{ index: number; channel: string; status: string }> };
};
```

```python Python theme={null}
import os

import requests

res = requests.post(
    "https://api.orbit.devotel.io/api/v1/notify",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    json={
        "mode": "waterfall",
        "body": "Your appointment is tomorrow at 10:30.",
        "fallback_window_seconds": 3600,
        "bindings": [
            {"channel": "whatsapp", "address": "+14155552671"},
            {"channel": "sms", "address": "+14155552671"},
        ],
    },
    timeout=30,
)
res.raise_for_status()
notify_id = res.json()["data"]["notify_id"]
```

```json 200 theme={null}
{
  "data": {
    "notify_id": "nfy_9h2k4p",
    "summary": { "total": 2, "succeeded": 2, "failed": 0 },
    "results": [
      { "index": 0, "channel": "whatsapp", "to": "+14155552671", "status": "queued", "message_id": "msg_w44p0n" },
      { "index": 1, "channel": "sms",      "to": "+14155552671", "status": "queued", "message_id": "msg_s77q1a" }
    ]
  },
  "meta": { "request_id": "req_notify_wf", "timestamp": "2026-09-13T12:00:00.000Z" }
}
```

In waterfall mode only the primary dispatches now; the remaining `results` entries are registered as armed hops rather than sent. If the WhatsApp hop returns a terminal receipt within 3600 seconds, the SMS hop fires as a brand-new send. `fallback_window_seconds` accepts 30 to 86,400 seconds (24 hours maximum) on this endpoint; omit it for the 24-hour engine default. Waterfall requires at least two bindings — a single-binding waterfall fails validation with a 400 naming `bindings`.

## Worked sample 2: Cost-bounded three-hop chain

`max_price` bounds each hop independently; `max_total_price` bounds the whole chase. Here RCS prices above its per-hop ceiling of `0.02` and is rejected before billing if armed, and the cumulative cap trims whatever would push the past-costly tail over `0.05`:

```bash cURL 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",
    "body": "Your pickup is ready at counter 4.",
    "max_price": 0.02,
    "max_total_price": 0.05,
    "fallback_window_seconds": 7200,
    "bindings": [
      { "channel": "rcs",      "address": "+14155552671" },
      { "channel": "whatsapp", "address": "+14155552671" },
      { "channel": "sms",      "address": "+14155552671" }
    ]
  }'
```

```typescript Node.js theme={null}
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",
    body: "Your pickup is ready at counter 4.",
    max_price: 0.02,
    max_total_price: 0.05,
    fallback_window_seconds: 7200,
    bindings: [
      { channel: "rcs", address: "+14155552671" },
      { channel: "whatsapp", address: "+14155552671" },
      { channel: "sms", address: "+14155552671" },
    ],
  }),
});

const { data } = (await res.json()) as {
  data: { notify_id: string; results: Array<{ index: number; channel: string; status: string }> };
};
```

```python Python theme={null}
import os

import requests

res = requests.post(
    "https://api.orbit.devotel.io/api/v1/notify",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    json={
        "mode": "waterfall",
        "body": "Your pickup is ready at counter 4.",
        "max_price": 0.02,
        "max_total_price": 0.05,
        "fallback_window_seconds": 7200,
        "bindings": [
            {"channel": "rcs", "address": "+14155552671"},
            {"channel": "whatsapp", "address": "+14155552671"},
            {"channel": "sms", "address": "+14155552671"},
        ],
    },
    timeout=30,
)
res.raise_for_status()
notify_id = res.json()["data"]["notify_id"]
```

```json 200 theme={null}
{
  "data": {
    "notify_id": "nfy_7c3m8r",
    "summary": { "total": 3, "succeeded": 3, "failed": 0 },
    "results": [
      { "index": 0, "channel": "rcs",      "to": "+14155552671", "status": "queued", "message_id": "msg_r11t2x" },
      { "index": 1, "channel": "whatsapp", "to": "+14155552671", "status": "queued", "message_id": "msg_w58k3p" },
      { "index": 2, "channel": "sms",      "to": "+14155552671", "status": "queued", "message_id": "msg_s92d4q" }
    ]
  },
  "meta": { "request_id": "req_notify_cap", "timestamp": "2026-09-13T12:00:01.000Z" }
}
```

A trimmed tail is never attempted, and the read-back receipt (below) names it in `dropped_for_cost_cap`. Per-binding `max_price` overrides the shared value; `max_total_price` itself is ignored in the default `fanout` mode, where every binding fires immediately and there is no cascade to cap.

## Worked sample 3: One org default for every send

When you want the same chain on every single-channel send without stamping fields per request, set the org policy once:

```bash cURL theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/messages/cascade/policy" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true, "channels": ["whatsapp", "sms"], "fallback_window_seconds": 3600 }'
```

```typescript Node.js theme={null}
const res = await fetch("https://api.orbit.devotel.io/api/v1/messages/cascade/policy", {
  method: "PUT",
  headers: {
    "X-API-Key": process.env.ORBIT_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    enabled: true,
    channels: ["whatsapp", "sms"],
    fallback_window_seconds: 3600,
  }),
});

const { data } = (await res.json()) as {
  data: { enabled: boolean; channels: string[]; fallback_window_seconds: number };
};
```

```python Python theme={null}
import os

import requests

res = requests.put(
    "https://api.orbit.devotel.io/api/v1/messages/cascade/policy",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    json={
        "enabled": True,
        "channels": ["whatsapp", "sms"],
        "fallback_window_seconds": 3600,
    },
    timeout=30,
)
res.raise_for_status()
policy = res.json()["data"]
```

```json 200 theme={null}
{
  "data": { "enabled": true, "channels": ["whatsapp", "sms"], "fallback_window_seconds": 3600 },
  "meta": { "request_id": "req_policy_put", "timestamp": "2026-09-13T12:00:02.000Z" }
}
```

Read it back with `GET /api/v1/messages/cascade/policy`:

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

```typescript Node.js theme={null}
const res = await fetch("https://api.orbit.devotel.io/api/v1/messages/cascade/policy", {
  headers: { "X-API-Key": process.env.ORBIT_API_KEY! },
});

// An org that has never set a policy returns { "policy": null, "configured": false }.
```

```python Python theme={null}
import os

import requests

res = requests.get(
    "https://api.orbit.devotel.io/api/v1/messages/cascade/policy",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    timeout=30,
)
policy = res.json()["data"]["policy"]
```

An org that has never set one returns `{ "data": { "policy": null, "configured": false } }`. Policy writes require the `messages:write` scope and an owner, administrator, or developer role, and each write records an audit-log entry. Senders keep their own chain: a per-send `cascade` field or a raw `metadata.fallback_channels` value beats the policy — sources are never merged.

## Worked sample 4: Per-send override and opt-out

Override the org policy for one send, or opt a send out of it entirely:

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/messages/rcs" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "body": "Your appointment is tomorrow at 10:30.",
    "cascade": { "channels": ["sms"], "fallback_window_seconds": 3600 }
  }'
```

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/messages/sms" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+14155552671", "body": "…", "cascade": { "enabled": false, "channels": ["sms"] } }'
```

```typescript Node.js theme={null}
const res = await fetch("https://api.orbit.devotel.io/api/v1/messages/rcs", {
  method: "POST",
  headers: {
    "X-API-Key": process.env.ORBIT_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    to: "+14155552671",
    body: "Your appointment is tomorrow at 10:30.",
    cascade: { channels: ["sms"], fallback_window_seconds: 3600 },
  }),
});
// To opt a send out of an existing org policy instead, send
// cascade: { enabled: false, channels: ["sms"] } on any channel send.
```

```python Python theme={null}
import os

import requests

res = requests.post(
    "https://api.orbit.devotel.io/api/v1/messages/rcs",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    json={
        "to": "+14155552671",
        "body": "Your appointment is tomorrow at 10:30.",
        "cascade": {"channels": ["sms"], "fallback_window_seconds": 3600},
    },
    timeout=30,
)
res.raise_for_status()
```

The typed `cascade` field accepts the same shape as the policy and defaults to `enabled: true`. Declaring a chain with `"enabled": false` — or setting raw `metadata.auto_fallback: false` — opts that one send out even when a chain exists; the resolution reads the false guard before it stamps anything.

## Reading a cascade back

Two read-back endpoints close the loop. The `/notify` receipt aggregates every hop of one envelope into a single status and a unified price:

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

```typescript Node.js theme={null}
const res = await fetch("https://api.orbit.devotel.io/api/v1/notify/nfy_9h2k4p", {
  headers: { "X-API-Key": process.env.ORBIT_API_KEY! },
});

const { data } = (await res.json()) as {
  data: { status: string; hops: Array<{ hop: number; channel: string; status: string; fallback_of: string | null }> };
};
```

```python Python theme={null}
import os

import requests

res = requests.get(
    "https://api.orbit.devotel.io/api/v1/notify/nfy_9h2k4p",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    timeout=30,
)
hops = res.json()["data"]["hops"]
```

```json 200 theme={null}
{
  "data": {
    "notify_id": "nfy_9h2k4p",
    "status": "delivered",
    "hop_count": 2,
    "delivered_channel": "sms",
    "delivered_at": "2026-09-13T12:01:19.000Z",
    "cost": { "total": 0.0175, "currency": "USD" },
    "max_total_price": null,
    "dropped_for_cost_cap": [],
    "truncated": false,
    "hops": [
      { "hop": 1, "message_id": "msg_w44p0n", "channel": "whatsapp", "to": "+14155552671", "status": "undelivered", "delivered": false, "fallback_of": null, "sent_at": "2026-09-13T12:00:04.000Z" },
      { "hop": 2, "message_id": "msg_s77q1a", "channel": "sms",      "to": "+14155552671", "status": "delivered",   "delivered": true,  "fallback_of": "msg_w44p0n", "sent_at": "2026-09-13T12:00:19.000Z" }
    ]
  },
  "meta": { "request_id": "req_notify_get", "timestamp": "2026-09-13T12:02:00.000Z" }
}
```

`status` is `delivered` when any hop landed, `failed` when the last hop terminated with nothing left armed, and `in_progress` while the cascade is live. The `cost.total` sums only the hops that actually fired — the armed-but-unsent tail is never billed. When a `max_total_price` trimmed the chain, `dropped_for_cost_cap` names the channels that never ran.

For policy-driven or per-send cascades, every hop shares one `message_group_id`, returned on send and readable as a group:

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

```typescript Node.js theme={null}
const res = await fetch(
  "https://api.orbit.devotel.io/api/v1/messages/cascade/groups/msg_7f3a9c2d1e4b",
  { headers: { "X-API-Key": process.env.ORBIT_API_KEY! } },
);

const { data } = (await res.json()) as {
  data: { legs: Array<{ id: string; channel: string; status: string; fallback_of: string | null; fallback_reason: string | null }> };
};
// An unknown group id returns an empty legs list, not an error.
```

```python Python theme={null}
import os

import requests

res = requests.get(
    "https://api.orbit.devotel.io/api/v1/messages/cascade/groups/msg_7f3a9c2d1e4b",
    headers={"X-API-Key": os.environ["ORBIT_API_KEY"]},
    timeout=30,
)
legs = res.json()["data"]["legs"]
```

```json 200 theme={null}
{
  "data": {
    "group_id": "msg_7f3a9c2d1e4b",
    "leg_count": 2,
    "legs": [
      { "id": "msg_01ab", "channel": "rcs", "to": "+14155552671", "status": "undelivered", "sent_at": "2026-09-13T12:00:04.000Z", "fallback_of": null, "fallback_reason": null },
      { "id": "msg_02cd", "channel": "sms", "to": "+14155552671", "status": "delivered",   "sent_at": "2026-09-13T12:00:19.000Z", "fallback_of": "msg_01ab", "fallback_reason": "primary_rcs_undelivered" }
    ]
  },
  "meta": { "request_id": "req_group_get", "timestamp": "2026-09-13T12:02:00.000Z" }
}
```

An unknown group id returns an empty `legs` list, not an error. A leg that failed to dispatch is simply absent, so the shape always reflects which hops actually ran.

## Guardrails: consent and quiet hours re-run on every hop

Every hop is a first-class new send, so the same gates apply at every hop of every plane:

* **Opt-out and consent.** The recipient's SMS opt-out blocks an SMS hop even if the primary on another channel was accepted. TCPA re-checks run on every hop into SMS.
* **Quiet hours.** Your tenant-configured quiet-hours windows gate each hop independently — a cascade armed during a quiet window holds the hop until the window opens, and the freshness window bound applies hop by hop.
* **Per-hop billing.** Each hop prices and charges on the channel it lands on; there is no fallback or cascade fee class.

Because gates re-run per hop, a hop that fails a gate stops the chain exactly as a terminal receipt would.

## The closed channel set

The eligible chain set for the cascade policy and the per-send `cascade` field is fixed: `sms`, `whatsapp`, `rcs`, `viber`, `telegram`, `messenger`, `instagram`, `line`, `apple_messages`. **Voice, fax, email, and push are never cascade-eligible on the messaging endpoints** — an undelivered email or call re-arming as a text is a compliance surface, not a convenience. A policy or `cascade` chain naming one of them fails validation with a 422 listing the rejected fields; the escalation engine additionally refuses hops into those channels even when a raw `metadata.fallback_channels` chain names them. `POST /notify` in the default fan-out mode accepts the full channel set, because there every channel fires once, immediately, by explicit declaration — it is the waterfall escalation into voice/email that is closed. Voice-specific fallback chains exist only on the verify product surface (see [Verify fallback chains](https://docs.orbit.devotel.io/guides/verify-fallback-chains.md)), not on messaging sends.

## Related

* [The five fallback planes](https://docs.orbit.devotel.io/concepts/fallback-and-cascade-planes.md) — precedence rules across all five planes.
* [Cascade failover policy and groups](https://docs.orbit.devotel.io/concepts/message-cascade-groups.md) — the policy and per-send model this page samples.
* [Fallback on terminal DLR](https://docs.orbit.devotel.io/concepts/multi-channel-dlr-fallback.md) — the escalation engine every plane arms.
* [Notify cascade cost model](https://docs.orbit.devotel.io/concepts/notify-cascade-cost-model.md) — `max_price` and `max_total_price` semantics.
* [Send gating and quiet hours](https://docs.orbit.devotel.io/concepts/send-gating-and-quiet-hours.md) — the per-hop gate chain.
* [Cascade fallback chains in the notify composer](https://docs.orbit.devotel.io/guides/notify-cascade-failover.md) — the dashboard composer over `POST /notify`.


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