Two different jobs get called orchestration, and conflating them is why multi-channel projects stall.
Journey orchestration decides whether to contact someone at all, when, why, and through which channel. It is business logic. It depends on customer state, consent, urgency, sensitivity, cost and what has already been tried.
Delivery operations takes a decision that has already been made and makes the attempt work: sender and route selection, provider adapters, throughput, retries, status mapping and failover within a channel.
These need different owners, different tests and different failure handling. Orchestration failures look like the wrong person being contacted, or contacted twice, or contacted after they had already paid. Delivery failures look like a route silently filtering traffic. No amount of routing sophistication fixes the first kind, and no journey builder fixes the second.
Orchestration also does not personalise or optimise anything on its own. It executes the policy you give it. If the policy is thin, the output is a well-instrumented version of a thin policy.
Model the customer state, not the channel workflow
The most consequential design decision is whether your system holds a canonical state for the customer or process, or a set of per-channel workflows.
Per-channel workflows are how you end up with an SMS sequence, an email sequence and a WhatsApp sequence that each believe they are in charge. They contact the same person about the same thing, they do not know what the others have done, and stopping one does not stop the others.
A canonical state is a single record of the thing being resolved: this invoice is unpaid, this appointment is unconfirmed, this verification is pending, this delivery needs a slot chosen. Every attempt on every channel is an attempt against that state, and the state — not the sequence — decides what happens next.
Practically, the state record holds: what needs to happen, the deadline if any, current status, which attempts have been made on which channels with what result, what evidence exists that the state is still current, and the stop condition.
Trigger events and evidence that the event is still current
Journeys are triggered by events, and events go stale. The most common orchestration failure is a message sent about something that has already been resolved — an invoice paid an hour ago, an appointment already confirmed, a code already used.
Two mechanisms prevent it. First, re-check state at the moment of send rather than only at the moment of scheduling. Anything scheduled more than a few minutes ahead should re-read the source of truth immediately before submission. Second, carry a state version or freshness timestamp with the request and reject stale decisions rather than trusting the queue.
This is also why event ordering matters. Out-of-order delivery of internal events, retried internal events, and duplicated webhooks from upstream systems will all produce spurious sends unless the state transition is idempotent.
Eligibility rules, evaluated at send time
Eligibility is a gate that runs before the decision policy, and it is not optional per channel. The set worth implementing explicitly:
- Consent — for this channel and this traffic class, with retrievable evidence of what was agreed and when.
- Suppression — opt-out, complaint, bounce and hard-failure history, propagated across channels and providers rather than held per provider.
- Channel availability — do you have a usable address or number for this channel, and is the channel actually reachable for this subject and market.
- Verified identity — has the number or address been validated, and for sensitive traffic, is verification recent enough.
- Locale — language, formatting, and the local time zone that quiet hours must be evaluated in.
- Quiet hours — evaluated in the subject's local time, per traffic class, with defined behaviour for what happens to a message that becomes eligible during quiet hours: defer, drop, or send anyway because it is genuinely urgent.
- Frequency — caps per channel and overall, per traffic class, over a rolling window. This is an internal customer-experience control; any legal or carrier restrictions are separate matters that must be checked per programme and destination.
- Traffic class — required on every request, because it drives sender selection, queue isolation and whether other gates apply at all.
The rule to hold to: eligibility is evaluated at send time, not at enrolment time. A journey that decided someone was eligible yesterday is not evidence of eligibility today.
Decision policy: which channel, and why
Once eligible, something has to choose. Make the inputs explicit rather than encoding a preference order in code:
- Preferred channel — stated preference first, where you have it.
- Urgency and deadline — how much time exists before the attempt stops being useful, which determines whether you can wait for a slower channel.
- Message sensitivity — what may appear on a lock screen, and what should not be in message content at all. Ordinary SMS should be treated as unencrypted in transit and readable on an unlocked device; account details, credentials and sensitive personal data do not belong in it.
- Risk — for verification and financial actions, the channel with the weakest identity assurance may be unacceptable even if it is cheapest and fastest.
- Cost — a legitimate input, and the one most often allowed to override the others silently. Record it as a policy input, not a default.
- Channel capability — whether the content needs richer formatting, attachments or interactive elements that a channel cannot carry.
Write the policy down as a table per use case. Most disputes about orchestration are actually disputes about this table that nobody has written.
Idempotency and deduplication across channels
Deduplication has to happen at the state level, not per channel, or fallback becomes a double-send mechanism.
What that requires: a single attempt record per state transition; an application-generated reference on every submission, stored before submission; a rule that a new attempt for the same state cannot be created while an attempt is in a non-final state, unless the timeout has expired; and — critically — the recognition that a timed-out attempt may still have been delivered. Fallback after timeout is a decision to accept possible duplication, and it should be a conscious one, taken per traffic class.
Attempt state, timeouts and final-state semantics
Each attempt needs its own small state machine with explicit semantics:
- created — decided, not yet submitted.
- submitted — accepted by the delivery layer with a reference.
- provider final — a canonical final status arrived (delivered, failed, expired, rejected).
- timed out — no final status within the useful window. Not the same as failed.
- superseded — the state changed or another attempt took over.
The distinction between "failed" and "timed out" is what makes fallback rational. A rejection is a definite signal you can act on immediately. Absence of a delivery receipt is a much weaker signal, because receipt reliability and meaning vary by channel, market and route. Some paths never return a final status at all, which is why reconciliation exists.
Fallback on deadlines and trustworthy signals
"Not delivered yet" is a poor fallback trigger. Design fallback around two things: a deadline that comes from the business process, and signals you actually trust.
Signals worth acting on quickly: an explicit rejection, an invalid-destination result, a capability mismatch, a hard suppression hit. Signals worth treating cautiously: absence of a delivery receipt, and a delivery receipt that arrived suspiciously fast on a route with an implausibly perfect success rate.
The deadline comes from the process. A verification code has minutes of usefulness. An appointment reminder has until the appointment. A dunning notice has until the due date. Set the fallback window from that, not from an arbitrary interval, and accept that for short-deadline flows you may deliberately choose parallel attempts on two channels rather than sequential fallback — a trade of possible duplication for time.
Fallback also needs a limit. Define the maximum number of channels tried, the total window, and what happens when the chain is exhausted: escalate to a human, surface it in the product, mark it unreachable, or stop.
Stop conditions
Every journey needs to answer, before it goes live: what makes this stop?
- The outcome happened — invoice paid, appointment confirmed, code entered, slot chosen.
- The state changed — appointment cancelled, order refunded, account closed, subject merged or deleted.
- The subject opted out, complained, or replied in a way that requires a human.
- The deadline passed and further attempts are pointless or harmful.
- A frequency cap was reached.
- An operational kill switch was pulled, per journey and per traffic class.
Stop conditions must be checked at send time, and the kill switch has to work without a code deployment. A journey without an owner-operable stop is an incident waiting for a bad data import.
Replies, inbound events and human escalation
Multi-channel means people reply, and on some channels they expect a conversation. Decide in advance: which inbound events are handled automatically (STOP and opt-out keywords, confirmations, simple choices), which update the canonical state, and which need a human — and where that human works, with what context.
Two failure modes are worth naming. An inbound reply that does not reach the state machine leaves a journey continuing to chase someone who has already answered. And an inbound reply that goes to a channel nobody monitors is worse than not offering the channel.
Opt-out handling in particular must be central and cross-channel. An opt-out captured on one channel or provider that does not propagate is both a customer-experience failure and a control failure.
Attribution with one journey ID
Use a single journey or correlation ID across every attempt on every channel for one state, carried into delivery records, link clicks, inbound events and the product outcome event.
With it, you can ask whether the process succeeded, which attempt preceded the outcome, and how much the later attempts contributed. Without it, per-channel reporting will credit the last channel that happened to send, and every channel's dashboard will look better than the process actually performed.
Be careful about claiming causation from attempt ordering. If it matters commercially, use a controlled holdout or a staged test on the orchestration policy itself.
Test orchestration separately from routes
These are different test suites and mixing them wastes days:
Orchestration tests use a stubbed delivery layer and assert decisions: correct channel chosen for a given state; stale event suppressed; opt-out respected; quiet hours deferred correctly across time zones; frequency cap enforced; no duplicate attempt for one state; fallback fired at the deadline; stop condition honoured when the outcome arrives mid-journey; exhausted chain escalated. All deterministic, no network.
Delivery tests exercise real routes: submission and status mapping per provider, webhook signature verification and replay handling, reconciliation of missing final statuses, retry and idempotency behaviour on ambiguous timeouts, throughput and burst shaping, failover with deduplication, and live canaries per market and route.
Then add a small number of end-to-end tests through both layers, and failure injection — provider timeouts, malformed callbacks, duplicate callbacks, out-of-order statuses, a route that accepts and never delivers.
Example state machine: appointment reminder
One use case, written out. No performance numbers, because they would be invented — the point is the shape.
Canonical state: appointment_confirmation, keyed on appointment ID. Deadline: appointment start.
| Step | Condition at send time | Action | Next |
|---|---|---|---|
| S0 created | Appointment booked, status unconfirmed | Schedule first attempt at T-72h | S1 |
| S1 attempt 1 | Still unconfirmed; consent for preferred channel; outside quiet hours; frequency ok | Send confirmation request on preferred channel; record attempt with reference | S2 |
| S2 await | — | Wait for final status, reply, or T-48h | S3 / S6 / S4 |
| S3 rejected or invalid | Definite failure signal on channel 1 | Mark channel unusable for this subject; fall back immediately | S4 |
| S4 attempt 2 | Still unconfirmed; eligible on fallback channel; deadline not passed | Send on fallback channel; same journey ID | S5 |
| S5 await | — | Wait for final status, reply, or T-24h | S6 / S7 |
| S6 confirmed | Reply or product event confirms | Stop journey; record outcome against journey ID | terminal |
| S7 exhausted | Chain exhausted, still unconfirmed, T-24h reached | Surface in staff worklist for a call; stop automated attempts | terminal |
| S8 superseded | Appointment cancelled or rescheduled at any point | Cancel pending attempts; open new state if rescheduled | terminal |
Notes that make it work in production: every step re-reads appointment status before sending; quiet hours are evaluated in the patient's or customer's local time; a cancellation event must cancel scheduled attempts rather than relying on the next step to notice; and if a reply arrives while an attempt is in flight, the reply wins.
The same shape applies to OTP recovery, with a much shorter deadline, a stricter channel policy on sensitivity and identity assurance, and a stronger argument for parallel rather than sequential attempts.
Implementation checklist
- Canonical state defined per use case, with deadline and stop conditions written down before build.
- State re-read and eligibility re-evaluated at send time, not at enrolment.
- Event freshness carried on the request; stale decisions rejected.
- Consent, suppression, availability, verification, locale, quiet hours, frequency and traffic class implemented as explicit gates.
- Channel decision policy written as a table per use case, with cost recorded as one input among several.
- Application-generated reference stored before submission; deduplication at state level.
- Attempt state machine distinguishing failed from timed out; reconciliation for missing final statuses.
- Fallback windows derived from process deadlines; parallel attempts considered for short-deadline flows.
- Chain limits and an exhaustion path that reaches a human.
- Inbound replies and opt-outs routed into the state machine and propagated across channels.
- One journey ID across attempts, clicks, inbound events and outcomes.
- Separate orchestration and delivery test suites, plus failure injection and live canaries.
- Per-journey kill switch operable without a deployment.
- Named owners: product owns state and outcomes, engineering owns the state machine, messaging operations owns routes and delivery, legal/compliance owns consent and retention requirements.
Where the layers usually live
Delivery operations is the part most teams should not build twice: adapters, routing, sender identity, throughput, retries, status mapping, reconciliation, monitoring and vendor escalation. Flowstates supplies and operates messaging channels and routes, supports customer-owned vendors through BYOV, and supports hybrid deployments, running that managed delivery layer.
Orchestration is more variable. It may sit in your own application, in a CRM or journey tool you already run, or in a Flowstates-supported workflow, and the right answer depends on where the canonical state and the business logic already live. Where the state lives in your product, the orchestration usually should too.
What is not realistic is treating orchestration as configuration you finish in an afternoon. The state model, eligibility rules and stop conditions are the work, and they are worth the time they take.