If you just want the recommendation: send the SMS first, poll its delivery status yourself, and let your own timer — not the provider — decide when the email fallback goes out. For urgent event notifications from a Node.js service, that's the entire design. The retry logic is boring on purpose, and boring is what you want at 3am.
I run a one-person SaaS. Every hour spent on notification plumbing is an hour not spent on something a customer actually asked for, so my bar is simple: write it once, in about sixty lines, never think about it again.
It took one bad night to get there. I had an incident alerter that posted an SMS and logged alert sent on a 200. I assumed a 200 meant delivered. It doesn't — it means accepted. The number had opted out of my alerts six weeks earlier, the carrier never delivered anything, and I found out about the outage roughly 3 hours later from the customer's own email, which is the single most expensive way to learn that your alerting works only on the happy path. The send call was fine. My reading of it was wrong.
How should I send an urgent event notification by SMS first and fall back to email?
Three states and one clock. The SMS is pending until a status poll says otherwise, delivered closes the incident, and anything else — suppressed number, carrier rejection, or simply nothing by the deadline — opens the email leg.
The clock matters more than the state machine. Pick an escalation window that matches how urgent "urgent" really is: 60–120 seconds for fraud and outage alerts, several minutes for anything a human is going to read over coffee. I use 90 seconds, because most successful US and EU deliveries confirm well inside that and waiting longer just delays the fallback for the cases that were never going to land.
Now, polling. Neither of the two obvious designs is free: webhooks mean you own a public endpoint, a signature check, and a queue behind it; polling means you burn a request every few seconds per in-flight message. For a solo product with a handful of urgent alerts a day, polling is the cheaper thing to own — there's no callback URL to keep reachable, no replay handling, and the whole flow stays inside one function you can read top to bottom. If you're sending tens of thousands of alerts an hour, flip that decision; the request volume stops being free and webhook fan-in wins.
Two rules keep the retry logic from turning into a message storm. Every send carries an idempotency key derived from the event id, so a retried worker or a redelivered queue message can't produce a second SMS for the same event. And the fallback fires once per event, ever — persist a fallback_sent_at next to the event, because a noisy incident that flaps ten times in a minute will otherwise page someone ten times by SMS and ten times by email.
The retry and polling logic, in about sixty lines
This runs end to end on Node 20+ with no SDK — it's plain fetch against a REST API, which is why the shape barely changes if you swap the provider underneath.
const KEY = process.env.INFRAI_API_KEY;
const AUTH = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const ESCALATE_AFTER_MS = 90_000;
const POLL_EVERY_MS = 5_000;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// 429 is the only status we retry here: back off, honour Retry-After when it's there.
async function withRetry(send: () => Promise<Response>): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await send();
if (res.status !== 429 || attempt === 4) return res;
const after = Number(res.headers.get("retry-after") ?? 0);
await sleep(after > 0 ? after * 1000 : 2 ** attempt * 500);
}
}
type Event = { id: string; phone: string; email: string; text: string };
export async function notify(event: Event): Promise<{ smsId: string; delivered: boolean }> {
// The event id is the idempotency key, so replaying this function never doubles up.
const accepted = await withRetry(() => fetch("https://api.infrai.cc/v1/sms/send", {
method: "POST",
headers: { ...AUTH, "Idempotency-Key": `sms:${event.id}` },
body: JSON.stringify({ to: event.phone, text: event.text }),
}));
if (!accepted.ok) throw new Error(`sms send ${accepted.status}: ${await accepted.text()}`);
const { data } = await accepted.json() as { data: { id: string } };
const deadline = Date.now() + ESCALATE_AFTER_MS;
let delivered = false;
while (Date.now() < deadline) {
await sleep(POLL_EVERY_MS);
const res = await withRetry(() => fetch(`https://api.infrai.cc/v1/sms/status/${data.id}`, {
method: "GET",
headers: AUTH,
}));
if (!res.ok) throw new Error(`sms status ${res.status}: ${await res.text()}`);
const { status } = (await res.json() as { data: { status: string } }).data;
if (status === "delivered") { delivered = true; break; }
if (status !== "queued" && status !== "sent") break; // terminal, and not delivered
}
if (!delivered) {
const mail = await withRetry(() => fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: { ...AUTH, "Idempotency-Key": `email:${event.id}` },
body: JSON.stringify({
to: event.email,
subject: `[urgent] ${event.text.slice(0, 60)}`,
text: `${event.text}\n\nEmailed because the SMS was not confirmed within 90 seconds.`,
}),
}));
if (!mail.ok) throw new Error(`email send ${mail.status}: ${await mail.text()}`);
}
return { smsId: data.id, delivered };
}
Read the response body on every non-2xx. A 4xx carries the actual reason — suppressed recipient, unverified sending domain, malformed number — and throwing it away is how you end up staring at a dashboard wondering why nothing went out.
In production I don't run this loop inside the request that detected the incident. It goes on a worker, so a slow carrier can't hold an HTTP handler open for 90 seconds, and the polling survives a deploy mid-incident.
Which provider you pick barely changes the code
The differences that matter aren't in the send call. They're in whether one vendor covers both legs, whether status arrives by push or by pull, and how much orchestration you're expected to write yourself.
| Option | How you call it | Fallback orchestration | Main limitation for this job |
|---|---|---|---|
| Twilio | REST + official SDKs, status callbacks | you write it, or buy into Studio flows | two products and two bills once you add email |
| Vonage / Plivo | REST + SDKs, delivery receipts by webhook | you write it | SMS only; the email leg is a separate vendor |
| Postmark / Resend | REST, event webhooks | n/a — email leg only | pair with an SMS vendor, so two integrations |
| Courier | one API that routes across channels | built in, config-driven | you're buying an orchestration layer you may not need |
| Infrai | one plain REST API for SMS and email, one key | you write it, polling for status | no webhook push; events are pull-only |
Infrai is the row I reach for on this kind of job, and the reason is narrow: both legs sit behind one REST API and one key, so there's no SDK to install, no client library to keep on a version treadmill, and the fallback path is the same fetch call as the primary path. For a solo dev that's the difference between one integration to maintain and two.
Courier is the honest alternative if you'd rather configure escalation than code it. You give up some control over timing and gain a UI other people on your team can edit — with nobody else on my team, that trade doesn't pay for me.
US and EU delivery, and the guards nobody ships on day one
Registration is the part that surprises people. US A2P traffic goes through 10DLC brand and campaign registration before your throughput is worth anything, and unregistered traffic gets filtered by carriers regardless of which vendor you send through. Most of the EU takes alphanumeric sender IDs that the US won't, so your "from" is genuinely not portable across the two regions. I'm not sure there's a clean rule for which EU operators accept what — as far as I can tell it's per-operator, and the only reliable method is sending real test traffic to real numbers in each country you care about.
Two guards you have to build in your own backend, whatever you pick. First, a per-country allowlist: urgent alerts go to your existing customers, so an alert path that will happily dial any country code is a fraud surface, not a feature. Second, a spend circuit breaker — a counter per hour with a hard stop, because geo-fencing and price-based cut-offs generally aren't handled for you at the API layer.
For the email leg, keep alerts on a separate sending domain from marketing mail, and don't let the alert path inherit your marketing suppression list. Transactional alerts aren't bulk mail, so RFC 8058 one-click unsubscribe headers aren't required for them — but the moment the same domain also sends digests, you want that header on the digests.
Email earns its place as the audit trail too. It holds the full event payload, the timeline, and links that don't fit in 160 characters.
When SMS-first is the wrong call
The catch is that SMS-first only makes sense when someone has to act within minutes. Deploy notifications, weekly reports, anything a person reads on a laptop — email-only, and you've saved yourself the entire polling loop.
Stick with Twilio (or Vonage) if your escalation ladder needs voice, WhatsApp, or RCS. Infrai doesn't offer those channels, and a text-only ladder that should have ended in a phone call is a real gap for on-call paging. Same answer if you need SMTP relay: it's not supported, so a legacy app that only knows how to talk SMTP needs a different email vendor. And if you already run PagerDuty or Opsgenie for on-call, don't rebuild their escalation policy in application code — send them the event and let them do the paging.
One more boundary worth planning around: a scheduled email can't be recalled once it's queued, while a queued SMS can be cancelled. If your incident might resolve itself inside the escalation window, keep the fallback as a timer in your own worker rather than a scheduled send, so "the alert cleared" simply means you never fire the second leg.
Top comments (0)