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

# Per-contact channel optimization: engagement-ranked dispatch

> How a campaign can shift each recipient onto their best-engagement channel at dispatch time — the candidate-set rules, the opt-in contract on campaign variables, credit-cap interaction, and the safe-channel envelope that bounds what dispatch may switch to.

# Per-contact channel optimization

A campaign sends every recipient on one declared channel. Per-contact channel
optimization relaxes that: each recipient goes out on the channel **their own
engagement history** says they respond to — picked from the channels you
already declared on the campaign. A recipient who always opens RCS gets RCS;
a recipient who answers SMS gets SMS; one campaign, the right channel per
person.

This page defines how the optimization decides, what bounds it, and how to
turn it on. The engagement data itself (delivery, open, reply events per
contact) is covered under the
[CDP event model](/concepts/cdp-event-model).

## Section 1 — The primitives that already existed

Two engagement-driven primitives shipped before this one. Both read the same
per-contact engagement profile, and neither changed **which channel** a
campaign dispatched on:

1. **Smart send (per-contact timing).** With
   `send_time_optimization: "recipient-optimal"` on a campaign, each recipient
   is scheduled at their next best-time window instead of a single send-at.
   The ranking behind it also computes a per-recipient channel hint — exposed
   as the `next_best_channel` block on
   `POST /campaigns/:id/preview-smart-send` — but the hint was **advisory
   only**. The blast executor still dispatched everyone on the campaign's
   declared channel.

2. **Delivery-based fallback.** The
   [cross-channel fallback](/concepts/cross-channel-fallback) chain walks a
   recipient down your channel list on a **delivery failure** signal (`failed`
   / `no_delivery` / `no_engagement`). That reacts to a failed send attempt;
   it never picks a better channel up front.

Channel optimization closes the gap: the same engagement ranking the preview
surfaced now feeds real dispatch. Instead of "here is the channel this
recipient would prefer — as a hint," the executor dispatches them on it.
One schedule is computed for the whole audience before the send loop starts,
then consulted per recipient.

## Section 2 — Where the ranking comes from

The ranking reads each contact's existing engagement profile: the per-channel
event counters the platform already maintains from delivery and engagement
traffic (the same counters smart send reads for send-time prediction). There
is **no new schema, no new data collection, and nothing to enable upstream**
— campaigns that have been sending already carry the history this feature
ranks on.

Per candidate channel, the ranking compares how much that contact has engaged
on that channel, with older events counting for less than recent ones. A
contact with no usable history gets a fallback recommendation, and the
campaign keeps them on the base channel.

Because the profile is per-contact and per-channel, the ranking can't invent
a channel you didn't offer it — it only ranks the **candidate set** the
campaign supplies, which the next sections bound.

## Section 3 — Opt-in contract

Channel optimization is **opt-in, default off**. A campaign turns it on with
one key in its `variables` object — a JSONB field carried on the campaign
resource, settable through the update-campaign API:

```json theme={null}
{
  "variables": {
    "channel_optimization": { "enabled": true }
  }
}
```

This is the same variables-keyed pattern as the other dispatch governors —
`adaptive_pacing`, `trust_tier_governor`, and `ab_bandit` all live under
`campaign.variables` — so one PATCH sets it and no migration or separate
resource is involved. Absent, malformed, or `"enabled": false` means every
campaign keeps its existing static-channel behaviour.

The candidates the feature ranks are derived from the campaign itself:

* The campaign's **base channel** is always a candidate.
* Every channel in the campaign's **fallback chain** that sits inside the
  safe-swap envelope (next section) joins the candidate set.
* If the base channel is outside the envelope, or fewer than two distinct
  safe candidates exist, there is nothing to optimize and the campaign sends
  exactly as before.

## Section 4 — The safe-swap envelope

Per-contact channel overrides are restricted to the same channel set the
budget-aware fallback already uses for mid-campaign downgrades: **SMS, MMS,
RCS, and Viber**. Four properties make that set safe to switch per recipient:

* **All four are phone-addressed.** The recipient is the contact's phone
  number on each of them — no identifier remapping when the channel changes.
* **No per-loop setup is skipped.** Email needs a subject and one-click
  unsubscribe headers; WhatsApp needs an approved template resolved before
  the send loop; voice needs consent, caller-ID, and TTS configuration. Those
  steps run once, keyed off the campaign's declared base channel — a
  per-recipient switch into one of those channels would silently bypass them.
  The envelope excludes those channels outright, so an override can never
  route a recipient somewhere the campaign is not set up to send.
* **Push and social channels** (messenger, instagram, line, telegram, kakao,
  zalo, wechat) are excluded for the same reason: identifier-based addressing,
  not E.164.

Consequence: a campaign whose **base** channel is email, WhatsApp, voice, or
push never receives per-contact channel overrides — the feature only operates
on campaigns that are already inside the envelope.

## Section 5 — Credit caps stay an upper bound

The ranking is cost-blind: an engagement preference for MMS over SMS is
legitimate, but MMS costs more per message. A campaign with a credit cap
estimates its spend per batch as *base-channel price × batch size* — if an
override routed recipients onto a pricier channel, real spend could exceed
the estimate and breach the cap.

So when the campaign has a credit cap configured, the candidate set is
narrowed to channels whose per-message price is **no more than the base
channel's**, priced through the same source the cap estimate uses. The cap
estimate then stays a strict upper bound on real spend. No cap configured —
the full engagement-ranked candidate set is used.

## Section 6 — Where the advisory preview ends

`POST /api/v1/ai/optimize-channel` still answers "which channel should a
single message go out on?" for one-off sends — it ranks and recommends, and
the returned ranking remains what your own sending logic does with it. That
endpoint never touched campaign dispatch; channel optimization is what wires
the ranking into the campaign executor. Use the endpoint for ad-hoc single
sends, the opt-in for campaigns.

## Safety and invariant notes

* **Tenant-owned controls only.** The opt-in lives on the campaign resource
  you own; the ranking reads your tenant's own engagement profiles, not any
  cross-tenant signal.
* **No new providers or channels.** An override only re-labels which of your
  campaign's already-declared channels a message goes out on. Outbound SMS,
  MMS, and RCS keep exiting through the Devotel softswitch exactly as before
  — the feature introduces no channel or provider the campaign didn't
  already declare.
* **Fail-closed.** Malformed opt-in config, an unpriced channel under a
  credit cap, or a failing profile read all resolve to "keep the base
  channel" — never to a guessed override.

## See also

* [Cross-channel fallback](/concepts/cross-channel-fallback) — the
  delivery-signal-driven chain this feature complements.
* [CDP event model](/concepts/cdp-event-model) — where the engagement
  counters come from.
* [Campaign lifecycle](/concepts/campaign-lifecycle) — where dispatch
  scheduling sits in the campaign state machine.
* [AI endpoints reference](/api-reference/endpoints/ai) — the single-send
  `/ai/optimize-channel` advisory endpoint.
