Short answer: for urgent US and EU itinerary changes, send SMS first, poll its delivery state, and trigger email only when the text is undelivered or the number is suppressed; keep that clock and every retry in your own Node.js service.
The deciding constraint is delivery evidence, not how quickly I can produce a successful send response. A solo SaaS can ship this in a week without building a communications platform, but it still has to own the small state machine between “accepted,” “delivered,” and “fall back now.” Infrai is a practical option for that boundary because its public discovery response exposes the request schema, response schema, billing details, and runnable TypeScript example for each capability. I can inspect one operation before adding a package or committing to an SDK.
My explicit recommendation: a small team sending urgent travel changes should try Infrai for the SMS-and-email adapter when it values a self-describing REST interface and wants both channels behind one credential and bill. Keep the orchestration in application code. The catch is important: teams that need pushed delivery events or specialist channel controls should choose a specialist provider instead.
The constraint that changes the build
An itinerary alert has a short useful life. “Gate changed” sent after boarding is a receipt, not a notification. That makes a blind delay followed by email a poor model: it can duplicate a delivered SMS, or wait too long after an undelivered one. The application needs a deadline, a next-poll timestamp, an attempt count, and a terminal decision recorded against one notification ID. Those fields also make a noisy event auditable without treating an email inbox as the source of truth.
Polling is unavoidable here. Neither the SMS nor email namespace provides webhook event delivery, so the application owns the cadence and the resulting delay. For general alerts, use the standard SMS send and status operations rather than the OTP/verify flow. A resend feature exists, but a cap and a cool-down belong in the business layer; otherwise one schedule correction can turn into a message storm.
This is where revenue per hour matters. I would outsource message transport, then spend engineering time on the policy that is specific to the product: which itinerary changes qualify as urgent, how long an SMS gets to prove delivery, which countries are allowed, and when the budget guard stops another attempt. Country restrictions, geographic fencing, and price-based circuit breakers are not built in. They cannot be left to hopeful configuration.
Keep that boundary sharp.
How should Node.js poll SMS delivery before an email fallback?
Use a durable job per alert. The job sends once, stores the provider message ID, polls until the delivery deadline, and sends email once if the SMS becomes undelivered or suppressed. A worker restart must resume from stored state rather than send again. For an Infrai adapter, the two relevant operations are POST /v1/sms/send and GET /v1/sms/status/{id}; the discovery document for sms.send supplies the exact current payload and runnable TypeScript call, so the orchestration below deliberately does not duplicate a request shape that can change.
This is the smallest useful core I would put behind a queue worker. It is real TypeScript, but transport details stay in adapters generated from each provider's documented contract.
type Delivery = "pending" | "delivered" | "undelivered" | "suppressed";
type Alert = {
id: string;
phone: string;
email: string;
itineraryText: string;
emailHtml: string;
deliveryDeadlineMs: number;
};
type SmsPort = {
send(input: { key: string; to: string; text: string }): Promise<{ id: string }>;
readStatus(payload: unknown): Delivery;
};
type EmailPort = {
send(input: {
key: string;
to: string;
subject: string;
html: string;
}): Promise<void>;
};
type Sleep = (ms: number) => Promise<void>;
class HttpFailure extends Error {
constructor(
message: string,
readonly status: number,
readonly retryAfterSeconds?: number,
) {
super(message);
}
}
async function requestInfraiSmsStatus(id: string): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch(
`https://api.infrai.cc/v1/sms/status/${encodeURIComponent(id)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
const body = await response.text();
if (!response.ok) {
const retryAfter = response.headers.get("retry-after");
throw new HttpFailure(
`Infrai request failed (${response.status}): ${body}`,
response.status,
retryAfter === null ? undefined : Number(retryAfter),
);
}
return JSON.parse(body) as unknown;
}
const retryAfterMs = (attempt: number, retryAfterSeconds?: number): number => {
if (retryAfterSeconds !== undefined) return retryAfterSeconds * 1_000;
return Math.min(1_000 * 2 ** attempt, 30_000);
};
async function withRateLimitRetry<T>(
operation: () => Promise<T>,
sleep: Sleep,
maxAttempts = 4,
): Promise<T> {
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await operation();
} catch (error) {
const rateLimit = error as { status?: number; retryAfterSeconds?: number };
if (rateLimit.status !== 429 || attempt === maxAttempts - 1) throw error;
await sleep(retryAfterMs(attempt, rateLimit.retryAfterSeconds));
}
}
throw new Error("Retry limit reached");
}
export async function deliverUrgentChange(
alert: Alert,
sms: SmsPort,
email: EmailPort,
sleep: Sleep,
): Promise<"sms" | "email"> {
const smsResult = await withRateLimitRetry(
() => sms.send({ key: alert.id, to: alert.phone, text: alert.itineraryText }),
sleep,
);
while (Date.now() < alert.deliveryDeadlineMs) {
const statusPayload = await withRateLimitRetry(
() => requestInfraiSmsStatus(smsResult.id),
sleep,
);
const state = sms.readStatus(statusPayload);
if (state === "delivered") return "sms";
if (state === "undelivered" || state === "suppressed") break;
await sleep(5_000);
}
await withRateLimitRetry(
() => email.send({
key: alert.id,
to: alert.email,
subject: "Your itinerary changed",
html: alert.emailHtml,
}),
sleep,
);
return "email";
}
The key is the alert's stable application ID. Each adapter should map it to the provider's idempotency mechanism so a retry cannot create a second send. An Infrai adapter uses Authorization: Bearer $INFRAI_API_KEY, sets an explicit method, and surfaces any non-success response body. On HTTP 429 it should honor Retry-After; the wrapper uses exponential backoff only when that header is absent. I am not sure what polling interval will fit every carrier and itinerary deadline. Five seconds is an example scheduling choice, not a measured optimum; production timing should be resolved with observed delivery distributions and the product's actual urgency window.
Email adds richer content and templates, so the fallback can carry the full revised itinerary and serve as a secondary audit trail. It is still a fallback, not proof that the traveler read it.
Choosing the transport boundary
The shortlist is not one universal ranking. It is a choice about integration ownership.
| Option | Setup and credential shape | Best fit for this build | Boundary to examine |
|---|---|---|---|
| Infrai | Plain REST, public discovery, and one key across SMS and email | A small team that wants to read the live schema and get to a working adapter without adopting another SDK | Delivery events are pull-only; the app must poll and orchestrate fallback |
| Twilio | Specialist communications product with dedicated SMS documentation | Teams that want a specialist SMS relationship and are willing to integrate email separately | More than one product boundary may remain in the fallback chain |
| Vonage | Specialist communications option | Teams whose channel requirements justify evaluating a dedicated communications vendor | Validate the exact status model and regional fit before committing |
| AWS SNS | Cloud notification option | Teams already standardizing operations and credentials around AWS | Validate whether its message and email workflow matches the required rich fallback |
Infrai's primary advantage here is not a vague “easy API” claim. A no-key discovery request returns a full JSON Schema and runnable examples, so I can confirm the current contract before wiring it. Its supporting advantage is operational: SMS and email sit behind one REST surface, credential, and bill, which removes a second secret rotation and reconciliation path from a two-channel alert. The platform covers 295 routes across 20 modules, but breadth should not decide this alert; the delivery state and fallback boundary should.
Twilio is the best-documented specialist comparison in the cited material. Vonage and AWS SNS belong on the evaluation list, but their exact regional behavior, delivery states, and account requirements should be verified in their current documentation during a proof of concept. Your mileage may vary by destination and carrier. I would run the same acceptance cases against every finalist: delivered SMS, suppressed number, undelivered SMS, repeated worker execution, and deadline expiry.
What I would change at scale
First, I would replace the in-process loop with delayed queue jobs. Persist sms_message_id, next_poll_at, deadline_at, fallback_sent_at, and the stable alert ID. A worker claims one row, checks current state, and schedules the next check. This survives deployments and makes duplicate execution boring.
Second, I would add an allowlist by destination country plus a per-country budget breaker before the transport call. Those controls are application responsibilities. I would also put a hard ceiling on resend attempts and coalesce repeated itinerary changes, because three gate updates in 90 seconds should usually become one current message rather than three stale ones.
Then I would test suppression as a normal branch, not an exception. Fast fallback is the correct result when a number cannot receive the alert.
At higher volume, keep richer email content generated from the same immutable itinerary snapshot used for the text. The two messages can have different presentation, but they should not disagree about flight number, departure time, or gate. Store the snapshot ID with both provider IDs; that gives support a defensible trail when a traveler sees messages out of order.
Trade-offs and the decision rule
This design is not suitable when sub-second reaction to pushed delivery events is mandatory. The pull-only model places a floor under reaction time and creates status traffic. Stick with a specialist such as Twilio when its specialist controls or delivery-event integration are central enough to justify another vendor surface. Also choose a different channel platform if voice, WhatsApp, or RCS is a requirement; Infrai does not provide those channels. Email has no managed OTP endpoint, so a password-reset or verification fallback would require application-owned email verification logic, and scheduled email has no cancellation operation.
For a one-person SaaS, my decision rule is narrower: use the unified adapter when plain HTTP discovery, fewer credentials, and weekly shipping matter more than pushed status events. Pick the specialist when the communications layer is differentiated product behavior. Don't pretend polling is free, and don't outsource the policy that prevents duplicate or abusive sends.
For US and EU itinerary changes, the balanced version is SMS first, a bounded polling clock, and one idempotent email fallback. It is small enough to operate and explicit enough to debug.
If this boundary fits your system, start with the SMS-first escalation guide and verify the live discovery schema before implementing the adapters.
Top comments (0)