Skip to main content

Per-message cascade policy on smart-send

POST /api/v1/messages/smart-send picks a channel with the smart router and dispatches on it. With a cascade_policy on the same request, the pick also arms a fallback chain: the router resolves your per-message policy into an ordered channel list and stamps it onto metadata.fallback_channels at send time — the exact metadata shape the terminal-DLR escalation hook consumes. An undelivered primary send then cascades to the next channel in the chain instead of stopping there. Without a cascade_policy, smart-send behaves exactly as before: it sends on the recommended channel with no fallback arm, and a terminal delivery failure stays final unless you stamped fallback metadata yourself. Set a cascade_policy when a smart-routed send must not die on the first channel — OTP and transactional traffic with reachability risk (RCS-first sends), or marketing sends where you want WhatsApp before SMS rather than a bare SMS hop. Leave it unset (or pass auto_fallback: false explicitly) for recipients whose consent dossier only covers the primary channel.

What the resolver resolves

The resolver runs between the router’s pick and the dispatch:
  1. Primary channel. Recommended by the smart router from your capability flags (whatsapp_available, email, rcs_available, …), message_type, urgency, and cost_optimize — the same scoring POST /messages/route-preview exposes. The policy never overrides the pick; it shapes only the fallback set. An explicit channel_override on the request still pins the channel as before.
  2. Candidate chain. Your explicit cascade_policy.fallback_channels when present — a full override — otherwise the router’s own fallback chain.
  3. Normalization. The chain is scrubbed: case-insensitively deduped, trimmed, the primary channel itself and voice/fax removed. A chain that survives normalization is stamped onto metadata.fallback_channels, with metadata.auto_fallback: true.
  4. Ordering. static when the chain is used as given; adaptive when per-segment learned ordering re-sorted it; off when auto_fallback: false suppressed fallback entirely. The ordering kind is recorded in the message metadata and on the audit event.
Both smart-send (the dispatch) and route-preview (the read-only check) resolve the policy the same way; the preview returns the resolved chain in a cascade block so you can inspect it before sending.

cascade_policy fields

Channel eligibility: messaging channels only

voice and fax are filtered out of the resolution, matching the fallback eligibility the terminal-DLR hook itself applies. The stamped chain is messaging channels only — sms, whatsapp, viber, rcs, email, and the OTT family. If you pass them, or if the router’s fallback was only reachable via voice (an OTP to a phone-only recipient), the chain normalizes to empty and nothing is stamped. auto_fallback: false resolves to an explicit off: the send’s metadata is stamped auto_fallback: "false" with an empty fallback_channels list, so even if a tenant-level default chain or an upstream pipeline would stamp one, this send must not escalate. Use it on smart-send pipelines that default to a chain but carry sends that must not hop channels. Omitting cascade_policy entirely is different: no policy resolves, and the send carries no cascade arm at all.

Segment hints: learned per-segment ordering

When cascade_policy.segment names one of segment_id, audience_id, or campaign_id, the resolver routes the chain through the learned cascade ranking: your own terminal-sends history over the last 30 days, blended per channel from carrier delivery and read/engagement rates, smoothed so a 1/1 channel can’t outrank a 900/1000 one. Below 20 pooled terminal sends the learned order is not trusted and the static order is kept. What changes for you: the stamped chain may come back re-ordered (ordering: "adaptive") — never with channels added or dropped. The ranking output is a strict permutation of the candidates, so a re-order can put RCS ahead of WhatsApp for a segment, but it can never invent a hop. The audit event records the ordering kind either way, so you can tell static from adaptive sends in the messages list.

Failsafe: any resolver error falls back to the static chain

Ranking reads historical rows over a rolling window and runs on every smart-send the policy wires. The resolver is built so nothing on that path can stop the send: a ranking failure (or a ranking that returns nothing usable) is caught inside the resolver, and the chain falls back to the static order — so no db handle sits between you and the dispatch. Segment-less policies skip the ranking lookup entirely: when segment names none of the three fields, resolution is pure and never touches the db.

Where it runs: both smart-send and route-preview

Both endpoints accept the same cascade_policy object, so a preview always shows what a send would stamp:
  • POST /messages/smart-send resolves the policy, stamps metadata.fallback_channels (+ auto_fallback) onto the message row, and dispatches on the router’s pick (or channel_override). The cascade.ordering kind is recorded in the audit trail.
  • POST /messages/route-preview resolves the same policy without sending. The response carries a cascade block with primary, the fallback_channels list, and the ordering, so a planning pass can see the exact chain a send would arm.

Worked example

Send with a three-channel explicit chain. The recipient is reachable on RCS and WhatsApp; we let the router pick the primary but pin the fallback order:
Response — 202 (the resolved cascade block mirrors what was stamped):
If the RCS primary comes back undelivered on the terminal DLR, the escalation hook consumes the stamped chain: it fires a new send on whatsapp carrying the remaining tail ["sms", "email"], and so on until the tail empties — the same consume-tail behaviour described in Fallback on terminal DLR. A sender whose pipeline defaults to a chain but must not escalate this one send passes "auto_fallback": false instead, and the metadata lands as explicitly off. Preview the same payload against POST /messages/route-preview first if you want to confirm the resolved chain; the cascade block returns identically.

Failure modes and edge cases

  • 422 VALIDATION_ERROR. cascade_policy fields are validated with the rest of the request — segment must be a string map, fallback_channels a string array. A chain with zero usable entries passes schema validation but resolves to no arm (nothing stamped).
  • Primary-echo entries dropped. Passing the router’s eventual pick inside fallback_channels (e.g. ["rcs", "sms"] when RCS wins) is not an error — the resolver drops it from the chain during normalization.
  • voice/fax in the chain are dropped, not rejected. A chain of only voice-plus-fax normalizes to empty; the send proceeds with no fallback arm and a 202.
  • auto_fallback: false is sticky. It wins over a legacy metadata.fallback_channels chain your pipeline would otherwise stamp: the resolver emits the explicit off before your metadata bag is merged, so the engagement hook sees the suppression.
The resolver never overrides the smart-router pick — channel_override still wins on the primary channel, and a policy shapes only the fallback set. To read more on the triggering side, see Fallback on terminal DLR (how the chain is consumed) and the Smart route preview guide (router scoring behind the recommendations).