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

# Smart routing: preview and validate a channel before you send

> Call POST /messages/route-preview to see which channel the smart router would pick, why, what it costs, and what falls back — before any message goes out. Wire the preview into campaign planning and send pipelines.

# Smart routing: preview a channel before you send

`POST /api/v1/messages/route-preview` runs the smart router **without sending anything**. You pass the recipient and the message shape; it returns the channel the router would pick, a plain-language reason, the ordered fallback chain, a per-message cost estimate, and a predicted engagement score. Use it to plan campaigns, validate a channel choice before you pace a send, and sanity-check fallback behavior against real recipients — all without spending a credit or creating a message row.

The same scoring logic runs on the live send path (`channel: "auto"` on `POST /messages`, and `POST /messages/smart-send`), so what the preview returns is what dispatch would do with the same inputs.

## When to preview vs. send

Preview when the answer changes what you send or how you pace it:

* **Planning a campaign.** Before you commit an audience to a channel, run the preview against a sample of recipients. If the recommendation is not the channel you assumed, adjust the audience or the capability flags — not the campaign after launch.
* **Validating channel-cost before pacing.** `estimatedCostCents` is priced through the same channel cost table the router scores with, so a preview loop over a recipient cohort gives you a per-channel cost picture for pacing decisions (throttle, credit caps) before the first dispatch.
* **Fallback sanity checks.** Toggle the capability flags (`whatsapp_available`, `rcs_available`, …) to confirm the router degrades the way you expect — e.g. that an RCS-first marketing blast lands on WhatsApp or SMS rather than voice when RCS is off.
* **Picking a send shape.** If `channel: "auto"` outperforms your fixed channel on engagement for a cohort, switch the campaign to auto-routing before you schedule it.

Send directly (skip the preview) when the message is one-off and you already know the channel, or when the recipient has exactly one reachable channel anyway.

## Endpoint shape

`POST /api/v1/messages/route-preview` — read-scoped, tenant-keyed rate limit at the standard read budget.

Your API key needs at most the `messages:read` scope (a write key works too). Create or edit keys in the dashboard under **Settings → API keys**.

### Request

| Field                | Type                                    | Notes                                                                                                                                                         |
| -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `to`                 | string                                  | **Required for routing.** Recipient phone in E.164 (`+14155552671`). SMS and voice enter the candidate set only when this is present.                         |
| `body`               | string                                  | Required — the full message body. Not used in routing math; keep it realistic so the same payload works against `/smart-send` later.                          |
| `message_type`       | `transactional` \| `marketing` \| `otp` | **Required.** `otp` is a hard rule — OTP always routes SMS with WhatsApp or voice as fallback and never scores other channels.                                |
| `urgency`            | `low` \| `medium` \| `high`             | **Required.** High urgency boosts SMS, voice, and push and penalizes email.                                                                                   |
| `whatsapp_available` | boolean                                 | Recipient is reachable on WhatsApp (24h window verified on your side).                                                                                        |
| `viber_available`    | boolean                                 | Recipient is reachable on Viber.                                                                                                                              |
| `rcs_available`      | boolean                                 | Recipient is reachable on RCS.                                                                                                                                |
| `push_available`     | boolean                                 | Recipient has an active push token.                                                                                                                           |
| `email`              | string                                  | Recipient email. Presenting one makes email a candidate.                                                                                                      |
| `country`            | string                                  | ISO 3166-1 alpha-2 (`"US"`, `"BR"`). Narrows pricing and carrier rules.                                                                                       |
| `cost_optimize`      | boolean                                 | Bias toward cheaper channels when they meet the quality threshold.                                                                                            |
| `engagement_rates`   | object                                  | Optional per-channel historical engagement for this contact, e.g. `{ "sms": 0.52, "whatsapp": 0.9 }`. Values 0.0–1.0; omitted channels use platform defaults. |

All capability flags default to absent (not `false`) — an absent flag means "unknown," which excludes the channel from the candidate set.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/messages/route-preview" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "body": "Your order #8421 shipped — track it here.",
    "message_type": "transactional",
    "urgency": "medium",
    "whatsapp_available": true,
    "rcs_available": true,
    "email": "sara@example.com",
    "country": "US",
    "cost_optimize": true,
    "engagement_rates": { "whatsapp": 0.91, "sms": 0.38 }
  }'
```

### Response — 200

```json theme={null}
{
  "data": {
    "channel": "whatsapp",
    "reason": "Best channel: high engagement (91%)",
    "fallback": "rcs",
    "fallbackChain": ["rcs", "sms", "email"],
    "estimatedCostCents": 3,
    "engagementScore": 0.91
  }
}
```

Nothing is created. There is no message id, no wallet deduction, no webhook, and no record of the preview anywhere but your own logs — call it on every recipient in a planning pass if you like.

## Read the response, field by field

* **`channel`** — the router's pick. One of `sms`, `whatsapp`, `email`, `viber`, `rcs`, `push`, `voice`. This is the channel a dispatch with the same inputs would use.
* **`reason`** — a plain-text explanation of why it won, composed from whichever factors fired: `cost-optimized` (cost-optimize is on and the pick is cheap), `high engagement (N%)` (the pick's engagement meets the 70% threshold), `fast delivery for urgent message`, `rich content for marketing`. When nothing distinctive fired, the reason states the pick scored highest for that recipient and message type. Two special cases: OTP messages return the regulatory SMS rule verbatim, and a recipient with no detected capabilities returns `"No channel capabilities detected — defaulting to SMS"`.
* **`fallback`** and **`fallbackChain`** — `fallback` is the next channel to try; `fallbackChain` is the full ordered list after the recommended channel, in priority order. The first entry always equals `fallback`. Wire the chain into your send logic so a hard failure on the primary hops to `fallbackChain[0]`, then `[1]`, and so on. An empty array means no other channel is reachable for that recipient — treat the primary pick as terminal.
* **`estimatedCostCents`** — per-message cost in cents on the recommended channel, before any volume tiering. Sub-cent channels (email, push at 0.1¢) come back as fractional values, so multiply rather than round when you sum a cohort. Multiply by your cohort size for a planning total.
* **`engagementScore`** — predicted engagement probability, 0.0–1.0. This is your `engagement_rates` value for the recommended channel when supplied, otherwise the platform default for that channel. Use it to compare channel strategies across a cohort — a marketing blast where auto-routing averages 0.7 and your fixed channel averages 0.45 is a signal to switch.

## Wire the preview into a send pipeline

The pattern: prefetch recipient capabilities, preview, then dispatch with the router's chain as your fallback ladder.

```text theme={null}
prefetch (capabilities, engagement history)
      -> POST /messages/route-preview  (read-only, cheap)
      -> dispatch on recommendation.channel
      -> on hard failure: advance through fallbackChain
      -> last resort: defer to org-level cross-channel fallback config
```

Node.js with the SDK (`@devotel/sdk-node`):

```javascript theme={null}
import { Orbit } from '@devotel/sdk-node'

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

async function sendWithPreview(recipient) {
  // 1. Look up what this recipient can actually receive on.
  const capabilities = await getCapabilities(recipient.id) // your store

  // 2. Preview — read-only, safe to call per recipient.
  const preview = await orbit.request('POST', '/api/v1/messages/route-preview', {
    data: {
      to: recipient.phone,
      body: recipient.messageBody,
      message_type: recipient.messageType,   // 'transactional' | 'marketing' | 'otp'
      urgency: recipient.urgency,            // 'low' | 'medium' | 'high'
      whatsapp_available: capabilities.whatsapp,
      rcs_available: capabilities.rcs,
      viber_available: capabilities.viber,
      push_available: capabilities.push,
      email: capabilities.email ?? undefined,
      country: recipient.country,            // 'US'
      cost_optimize: true,
      engagement_rates: recipient.engagement, // { sms: 0.4, whatsapp: 0.8, ... }
    },
  })
  const recommendation = preview.data

  // 3. Dispatch on the recommended channel via the unified send.
  //    An explicit channel pins the pick; the same payload also works
  //    with channel: "auto", which re-runs the identical scoring at send time.
  const chain = [recommendation.channel, ...recommendation.fallbackChain]
  for (const channel of chain) {
    try {
      return await orbit.request('POST', '/api/v1/messages', {
        data: { to: recipient.phone, body: recipient.messageBody, channel },
      })
    } catch (err) {
      // On a hard, non-retryable failure, advance to the next hop.
      if (!isHardFailure(err)) throw err
    }
  }
  throw new Error('Exhausted fallback chain')
}
```

Two notes on the pipeline:

* **Trust the chain, not your own ladder.** The returned `fallbackChain` already encodes capability and cost ordering. Hand-rolled ladders skip that math.
* **Org-level fallback still applies.** If your tenant has cross-channel fallback rules configured (Settings → Channels → Cross-Channel Fallback), transparent retry on terminal delivery failures continues below your chain — see [Messaging Best Practices](/guides/best-practices#implement-channel-fallback).

## Preview interactively: Outbound → Route Preview

The dashboard surfaces the same endpoint as an operator tool, so a non-API teammate can answer "what would Orbit do here?" without writing code.

Navigate to **Outbound → Route Preview**. Fill in the recipient (E.164 phone), optionally an email and country, pick the message type and urgency, toggle the recipient's reachable channels and the cost-optimize bias, then select **Preview route**. The result shows the recommended channel with its reason, the fallback order as a pill chain, the per-message estimate in your account currency, and an engagement bar. Owner, admin, and developer roles can run a preview; viewers cannot — matching the API's read-scope posture. Nothing is sent from this screen either.

Use the dashboard for one-off validation ("does enabling cost-optimize swap SMS for WhatsApp on this Brazil recipient?"); use the API when the preview runs inside a pipeline.

## route-preview vs. the composite risk verdict

The two pre-send lookups answer different questions — use both on high-cost traffic, in this order:

|           | `POST /messages/route-preview`                    | `POST /risk/score`                                                                  |
| --------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Question  | **Which channel** should this message take?       | **Should this send happen at all?**                                                 |
| Returns   | Channel, reason, fallback chain, cost, engagement | 0–100 fraud verdict, band, `allow` / `review` / `block` recommendation              |
| Inputs    | Recipient capabilities, message type, urgency     | Destination, message body (URL reputation), optional verify/biometrics pass-through |
| Advisory? | Yes — picks a channel; sends nothing              | Yes — scores; never blocks by itself                                                |

Run `risk/score` first on unfamiliar or high-cost destinations: a `high`-band verdict is a reason to refuse the destination regardless of what the router recommends. For destinations you accept, `route-preview` settles the channel. Both are read-only lookups — see the [SMS pumping protection guide](/guides/sms-pumping-protection#composite-verdict) for the risk verdict and the cut-offs behind its bands.

## Failure modes and safety properties

* **Read-scoped keys work.** A `messages:read` key can call the endpoint; you do not need to hand a planning job a write key.
* **Nothing ever sends.** The endpoint runs the scoring function and returns — it writes no message row, touches no wallet, and emits no webhook. Repeatedly calling it in a planning loop is safe and cheap.
* **No cross-tenant leakage.** Responses are computed from the request body plus platform defaults. When you omit `engagement_rates`, the defaults are global per-channel constants — the preview never reads another tenant's data, and it returns your own historical rates only when you supply them.
* **`422 VALIDATION_ERROR`.** A malformed body (missing `to`, `body`, `message_type`, or `urgency`; bad enum value; engagement rate outside 0–1) fails schema validation before any routing runs. Validation errors are reported once per field with a human-readable message — fix the named field and retry. The fix path does not need a retry loop.
* **All-capabilities-off is not an error.** A recipient with no flagged capabilities and no email gets a 200 with `channel: "sms"` and the diagnostic reason `"No channel capabilities detected — defaulting to SMS"`. Decide in your pipeline whether that fallback suits the recipient — it is the router's last-resort default, not a delivery guarantee.
* **Rate limit.** The route sits behind the tenant-keyed read budget (per-tenant, per-minute). A planning pass over a large cohort should still pace calls or batch over seconds — a `429` returns `retry_after` and is safe to retry after that window.

<Note>
  The preview is deterministic — same inputs, same recommendation. Change the capability flags, urgency, message type, or `cost_optimize` to see how the router's answer moves; the response's `reason` tells you which factor fired.
</Note>
