Short answer: model a travel change as one consent-aware notification record, submit SMS first, poll its delivery state, and hand the same record to email when a bounded policy says to do so. The integration effort stays manageable when the itinerary service knows only an internal channel interface, while channel adapters own provider-specific details.
The before picture is a flight-update handler that waits on an SMS request. The after picture is a small ledger: an itinerary event, an SMS submission, observed delivery states, and an explicit email decision. That difference matters when a gate changes twice and support needs one timeline instead of two disconnected sends.
The event ledger is the product boundary
Treat the notification record as part of the travel domain, not as a wrapper around a vendor SDK. It should answer four questions without another network call: what changed, who was eligible, which channel accepted the message, and why the next channel was or was not used. A compact ledger also makes migration possible because a new transport can consume the same event history instead of inventing a second state model.
Keep one record per itinerary event and recipient. A new flight time is a new event; a repeated worker run is not. That distinction prevents a retry from becoming a second traveler conversation.
How can Node.js route SMS, email, polling, and retries for US and EU trips?
Start with a durable state machine. accepted means the channel accepted a request; it does not mean the traveler saw it. A worker should persist the channel correlation ID, recipient region, consent snapshot, attempt count, next poll time, and a hard deadline. Polling is a scheduled observation, not another send.
Here is the core contract. The adapters can wrap any standards-based or commercial transport without leaking that choice into the travel domain.
type State = "queued" | "submitted" | "delivered" | "failed" | "expired";
type Notice = {
id: string;
phone?: string;
email: string;
state: State;
correlationId?: string;
attempts: number;
nextPollAt: number;
deadline: number;
region: "US" | "EU";
};
interface SmsChannel {
submit(to: string, text: string): Promise<{ correlationId: string }>;
status(correlationId: string): Promise<"delivered" | "pending" | "failed">;
}
interface EmailChannel {
send(to: string, subject: string, text: string): Promise<void>;
}
const backoff = (attempt: number) => Math.min(60_000, 1_000 * 2 ** attempt);
async function advance(
notice: Notice,
sms: SmsChannel,
email: EmailChannel,
now = Date.now(),
) {
if (notice.state === "queued") {
if (!notice.phone || now >= notice.deadline) {
await email.send(notice.email, "Itinerary change", "Your trip details changed.");
notice.state = "expired";
return;
}
const result = await sms.submit(notice.phone, "Your itinerary changed. Open your trip page for details.");
notice.correlationId = result.correlationId;
notice.state = "submitted";
notice.nextPollAt = now + backoff(notice.attempts++);
return;
}
if (notice.state !== "submitted" || now < notice.nextPollAt) return;
const result = await sms.status(notice.correlationId!);
if (result === "delivered") notice.state = "delivered";
else if (result === "failed" || now >= notice.deadline) {
await email.send(notice.email, "Itinerary change", "Your trip details changed.");
notice.state = "expired";
} else {
notice.attempts += 1;
notice.nextPollAt = now + backoff(notice.attempts);
}
}
The real worker needs an idempotency key, such as (notice_id, channel, operation), and a short lease around each row. Without both, a slow poll can overlap a retry and send the fallback twice. Keep provider status and normalized status together; raw codes are useful during an incident, while normalized states keep policy readable.
What does consent change between US and EU recipients?
The delivery state machine can be shared. The consent evidence cannot be an afterthought. Store purpose, source, timestamp, region, and the latest opt-out decision beside the recipient profile. For example, when a Paris-to-Boston connection moves from 14:10 to 16:05, the worker should evaluate the stored travel-alert purpose before submitting the SMS, then evaluate it again before an email fallback. A travel-change alert may be operationally necessary, but promotional follow-ups are a different purpose and should not inherit that permission. Keep the evidence immutable enough for an audit, while allowing a later opt-out to stop future attempts.
Email needs a visible unsubscribe path for messages that are subscription-like; RFC 8058 defines a one-click mechanism for list-unsubscribe requests. SMS sender registration, quiet hours, and carrier filtering vary by destination. Put those rules in a region-and-channel policy table, not in a chain of if statements inside the itinerary handler.
Keep policy data separate from delivery data.
| Decision | Stored input | Result |
|---|---|---|
| Send SMS | region, phone, travel-alert consent | submit and poll |
| Try email | deadline, SMS state, email consent | send once with an idempotency key |
| Stop | opt-out, delivered state, expired deadline | no further channel call |
I once treated a successful submit response as proof of arrival. It was only an acknowledgement from the transport. The useful evidence arrived later, in a status observation. That is why submitted_at, last_status_at, provider code, and deadline belong in storage. Don't collapse those timestamps into one updated_at field: during a gate change, the difference between “submitted” and “delivered” is the difference between a useful support answer and a guess.
Where should fallback and observability live?
Fallback belongs in the orchestration layer. The SMS adapter reports facts; it should not decide whether email is allowed. The orchestrator can apply a policy such as “email after ten minutes,” “email immediately for a permanent number failure,” or “do not send when the traveler opted out.” Replaying the itinerary event then consults the existing notice rather than creating a second one.
Use an outbox transaction when the itinerary update and its notification must agree: commit both records, then let a worker publish the outbox row. Measure queue age, SMS submission latency, delivery latency, fallback rate, and duplicate suppression. Logs should include the notice ID and correlation ID, never the full phone number or message body.
The trade-off is real. This ledger, lease, and polling loop cost more integration work than one synchronous API call. It is not suitable when the product needs a hard guarantee that a person has read the message; use an interactive acknowledgement channel for that requirement. It is also a poor fit for bulk marketing unless a separate queue and consent system are designed for that volume. Stick with email-only when itinerary changes are low risk and the team cannot operate a worker. Choose SMS first when a traveler may miss email and a short, urgent alert is worth the extra state.
Short version: one event, one notice record, bounded SMS observation, then a policy-driven email handoff. Carrier delivery is probabilistic, so the system should make uncertainty visible instead of pretending that a request was a receipt.
Top comments (0)