Skip to main content

Unified termination routing

A submit_sm from your SMPP bind (or any REST/campaign send) is a message, not necessarily an SMS. Orbit’s unified termination engine decides which channel actually delivers it — the Devotel softswitch’s SMS leg, a WhatsApp template, or any of the other messaging channels — while still billing the send exactly once. The unit of policy is a termination rule; the executor is the terminator; and the write-it-down decision is a single evaluate → claim → deliver flow that runs inside the send preflight. Everything on this page is tenant-owned configuration. Rules, receipts windows, and per-hop policy live in your tenant schema and are evaluated against your traffic only.

1. Why unified termination — one billed egress

Without a termination layer, an absorbed submit (e.g. SMPP in, WhatsApp out) is billed once by the SMPP preflight and again by the channel send — and whichever downstream provider you pick can’t be trusted to idempotency-dedupe the second charge. The terminator’s claim collapses the second send, just as the charge key collapses the second charge:
  1. The preflight prices and debits charge:msg:<messageId> before routing decisions run.
  2. The intent envelope carries messageId and chargeKey verbatim; nothing downstream re-mints them.
  3. The terminator executes a conditional UPDATE, claims the intent, and calls the channel’s send once, passing the pre-existing messageId so the second deduct lands on the already-claimed key and silently resolves to a no-op.
Because pricing runs before the BYO-carrier short-circuit and after the deny gates, the decision is made on the priced row — never on a $0 placeholder. A rule that moves a message off the ingress channel (e.g. SMS→WhatsApp) is the rule that makes a second, cheaper leg possible. That is the entire point of the engine’s existence.

2. The terminator in the routing preflight

The terminator is the one code path where a routed message is actually sent. Two guards keep it that way, and both are structural, not discipline:
  • The decision package (@devotel/message-routing) depends on @devotel/shared and zod only, so it cannot import the messaging router and can therefore never reach router.send. This converts the historical unbilled-send class from a review item into a compile error.
  • A test asserts exactly one sendMessage( call under the termination module, so any new egress site fails CI.
That means the TerminationPlan is a decision, not a send. The plan names steps (deliver/absorb), the terminator projects it onto whichever channel the plan chose and performs exactly one egress. The split is the same pattern your fallback planes use: fallback decides the next channel; the send pipeline owns billing, consent, and delivery.

3. The rules model: deliver vs absorb hops

A rule either delivers the message onto a channel, or absorbs the message (Orbit takes ownership so the ingress does not deliver). The two shapes are deliberately the only actions the engine can express:
  • deliver picks a target channel, whose-account-it-goes-on (prefer_tenant / tenant_only / platform), and per-hop knobs (template_*, max_price_cents, receipt_timeout_seconds, inbox: 'thread' | 'none').
  • absorb is terminal: Orbit has taken ownership, so the original ingress (usually your SMPP bind) must not also deliver. Getting absorbIngress right in either direction is the difference between a double-send and a silent drop.
Any deliver step is itself an absorb for the ingress: if the engine is delivering on a channel, the original ingress does not deliver too. The first enabled rule whose match conditions are all true (AND semantics) wins — there is no nesting, no boolean tree, no OR (an “or” is expressed as two rules with their own priorities). Rules select a channel, never a provider, connector or trunk. Invariant #45 holds by construction: the bootstrap registers every messaging channel with exactly the Devotel wholesale softswitch; the engine’s action vocabulary is a channel, so a tenant rule cannot name a provider. A rule that moves a message off the ingress must also satisfy its own require_dlr_mode (webhook/both) because the ingress-native receipt would otherwise black-hole.

4. Shadow → enforce lifecycle

Every rule starts in mode: "shadow". Shadow is the safety story for letting tenants author rules that spend: the rule is evaluated, the would-be chosen channel is recorded on the intent row, and the submit proceeds on the normal path exactly as if the rule didn’t exist.
  • Shadow — the decide outputs a demoted intent; nothing moves money; the attempted_channels array records the hops the rule would have tried.
  • Enforce — the terminator claims the intent, dispatches the first deliver hop, and records the outcome (delivered_to_provider, failed, or demoted).
You read shadow hints from termination_intents.attempted_channels and reason on the intent row — that is the evidence a would-have-fired rule leaves behind. Flip to enforce once the shadow trace matches your expectation (e.g. the hop choice, the recipient set, the price ceiling). The transition is a PATCH on the same rule endpoint; nothing needs to be re-created.

5. Rules evaluation order + canonical failure modes

Rules are evaluated in ascending priority; ties are broken by created-at. First enabled match wins. A rule’s failure_count is stamped by the terminator when its hop fails, so the health/pause badge layers on with no new DDL. Canonical failure modes, in the order you will see them:
  1. Passthrough — no rule matched, or every rule errored. The engine degrades to “no rules,” which is today’s behaviour. This is observable: the in-process evaluation-error counter is incremented and a Datadog metric stamped, and the fallback is logged; a malformed rule degrades to “skipped,” not to a thrown preflight.
  2. Template-parameter unbound — a rule references {{body}} or {{match.N}} and the regex/substitution can’t satisfy it. The engine returns passthrough rather than absorbing into a blank template send that the provider rejects after the ingress was ACKed.
  3. Receipt black hole — a rule whose ingress was SMPP in bind mode has a declared require_dlr_mode (webhook/both). Rule-write rejects the offending combo at write time, not at 3am.
  4. Claim loss — the terminator only sends when a conditional UPDATE flips dispatch_state from pending to dispatching and a re-read confirms it; zero rows returned means a different worker owns the intent and no send happens.
  5. Provider short-circuit bought but not delivered — the terminator’s recursion guard (skipTermination flag on its send call) is what prevents decide → terminate → sendMessage → decide → … from recursing.

6. How LCR dry-run quotes and fallback planes plug in

Termination rules sit after sender resolution and before the BYO-carrier short-circuit; the decision is one hop upstream of the route LCR ranks. The dry-run quote (see Least-cost routing) simulates which carrier the engine would pick and what it would cost, but it never moves traffic — use it to preview a termination candidate’s cost before you flip enforce. For escalation semantics once a hop fails, the fallback planes decide the next channel; termination decides the first hop. Read Fallback and cascade planes for the five-plane map.

7. A worked request — create a rule and move it to enforce

The rule below absorbs inbound submit_sm traffic on your otp_esme_01 bind for +44 destinations onto WhatsApp, uses a deliver hop whose inbox: 'none' keeps it out of the conversation queue (see Keep one-time codes out of the Inbox for the full flavour):
Submit a real OTP out of scope of the preview (still in shadow) to watch what the rule would have chosen. When the trace matches, flip the rule to enforce:
The same actions body shapes the dashboard’s channel-selector flow (Developer → SMPP → termination rules): pick deliver with the target channel, and the selector exposes the same hop fields the API accepts.

See also