When the alert email bounces, text the human: an SMS fallback
Poll the email event feed instead of waiting for a webhook, classify the bounce, and escalate hard failures to SMS — with Node 22 code and real response shapes.
The honest version of a cross-channel fallback is short: send the email, watch its event feed, and if the feed says the mailbox rejected it, spend a cent on a text instead. On Infrai that’s three routes — POST /v1/email/send returns a message_id, GET /v1/email/event/list replays what happened to it, and POST /v1/sms/send carries the escalation. All on one key, which matters more than it sounds, because a fallback that spans two vendor accounts is a fallback nobody tests.
Notice what’s missing: a webhook. Infrai’s email and SMS surfaces publish no delivery callback, so you poll. For a system that sends thousands of alerts an hour that’s a genuine drawback and you should weigh it; for the alerting volumes most SaaS teams actually have, a poll every 20 seconds against a bounded set of in-flight ids is less machinery than a public HTTPS endpoint with signature verification and replay protection.
Which failures deserve a text message
Not every bounce is worth $0.007. The event feed distinguishes them, and RFC 3463’s status classes are the vocabulary underneath most of what providers report.
| Event on the feed | What it means | Fallback action |
|---|---|---|
queued, sent | Accepted by the vendor, in flight | Wait — nothing has failed |
delivered | The receiving MTA took it | Done; cancel the watch |
bounced (5.x.x, hard) | Mailbox doesn’t exist, domain rejects you | Text the user, suppress the address |
bounced (4.x.x, soft) | Full mailbox, temporary defer | Wait one retry cycle first |
complained | Marked as spam | Never re-send to that address; text only if the alert is safety-critical |
| No terminal event by deadline | Silent drop or slow MTA | Text if the alert is time-boxed, otherwise keep waiting |
The soft-bounce row is where teams overspend. A 4.x.x deferral usually clears on the vendor’s own retry, so escalating immediately doubles your cost and trains recipients to ignore both channels.
Send, and hold onto the id
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "ops@example.com",
"from": "alerts@yourdomain.com",
"subject": "Billing run failed for tenant 4471",
"html": "<p>The nightly billing run exited 1. Runbook: https://yourdomain.com/rb/billing</p>"
}'
{
"ok": true,
"data": {
"message_id": "msg_2ZhTtleGakhMuXd68qzTrugF",
"state": "queued",
"channel": "email",
"to": "ops@example.com",
"vendor": "resend"
}
}
Store that message_id next to the alert row in your database, with a watch_until timestamp. It’s the join key for everything that follows.
Poll the event feed
The feed takes the message id as a query parameter — it’s a GET, so don’t send a body.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_2ZhTtleGakhMuXd68qzTrugF" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"type": "sent",
"at": "2026-07-26T00:58:24.831626Z",
"recipient": "ops@example.com",
"message_id": "msg_2ZhTtleGakhMuXd68qzTrugF",
"meta": { "vendor_message_id": "c4cd40bb-0ee0-4fd6-9b3e-fd1932d0e777" }
},
{
"type": "queued",
"at": "2026-07-26T00:58:24.820703Z",
"recipient": "ops@example.com",
"message_id": "msg_2ZhTtleGakhMuXd68qzTrugF",
"meta": { "vendor": "resend" }
}
],
"next_cursor": null,
"count": 2
}
}
Newest first, with a next_cursor you follow when a message has a long history. An id that isn’t in the account’s archive answers EMAIL_NOT_FOUND with HTTP 404 rather than an empty list, which is a useful distinction — empty means “nothing yet”, 404 means “you’re asking about the wrong thing”.
The watcher, in Node 22
One process, no dependencies, no callback endpoint. It takes a set of in-flight alerts, reads each feed, and escalates the ones that failed.
// bounce-watch.mjs — Node 22 ESM
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const HARD = new Set(["bounced", "rejected", "suppressed", "failed"]);
const TERMINAL_OK = new Set(["delivered", "opened"]);
async function request(path, init = {}) {
const res = await fetch(BASE + path, {
...init,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
});
const payload = await res.json().catch(() => ({}));
if (res.ok && payload.ok !== false) return payload.data ?? payload;
const err = payload.error ?? {};
const reason = `${err.code ?? "HTTP_" + res.status}: ${err.message ?? "unknown"}`;
// A 503 that names the address is bad input dressed as an outage; retrying it
// just loops. Anything else 5xx is worth another pass.
const transient = res.status >= 500 && !/e\.164|invalid|malformed|recipient|address/i.test(err.message ?? "");
throw Object.assign(new Error(reason), { transient, code: err.code });
}
async function verdict(messageId) {
let feed;
try {
feed = await request(`/v1/email/event/list?message_id=${encodeURIComponent(messageId)}`);
} catch (error) {
if (error.code === "EMAIL_NOT_FOUND") return { decision: "unknown", detail: "not in archive" };
throw error;
}
for (const event of feed.items ?? []) {
if (HARD.has(event.type)) return { decision: "escalate", detail: event.type, at: event.at };
if (TERMINAL_OK.has(event.type)) return { decision: "done", detail: event.type, at: event.at };
}
return { decision: "pending", detail: `${feed.count ?? 0} events so far` };
}
async function textInstead(phone, subject) {
return request("/v1/sms/send", {
method: "POST",
body: JSON.stringify({
to: phone,
body: `Email to your inbox bounced. ${subject}. Check the dashboard.`,
from: "+14155550100",
}),
});
}
// alert row -> { messageId, phone, subject, deadline }
const inFlight = [
{ messageId: "msg_2ZhTtleGakhMuXd68qzTrugF", phone: "+14155550142", subject: "Billing run failed", deadline: Date.now() + 600_000 },
];
for (const alert of inFlight) {
const result = await verdict(alert.messageId);
const expired = result.decision === "pending" && Date.now() > alert.deadline;
if (result.decision === "escalate" || expired) {
const sms = await textInstead(alert.phone, alert.subject);
console.log(`escalated ${alert.messageId} (${result.detail}) -> ${sms.message_id}`);
} else {
console.log(`${alert.messageId}: ${result.decision} (${result.detail})`);
}
}
Run it on a 20-second timer — INFRAI_API_KEY=your_infrai_api_key node bounce-watch.mjs — and keep the in-flight set in Postgres rather than an array, so a restart doesn’t drop every pending escalation on the floor.
One detail in that code repays a second look. The 503 check exists because a malformed recipient on either send route comes back as VENDOR_DOWN with retryable: true, and the real cause sits in message. A naive retry-on-5xx wrapper will hammer a permanently invalid address until something else breaks. Read the message, then decide.
Two rules that stop the fallback becoming the problem
The first is idempotency at the alert level, not the send level. Write the escalation decision — alert id, chosen channel, timestamp — before you call the SMS route, and make the watcher skip any alert that already has a row. Two overlapping poll cycles reaching the same conclusion is the normal case, not the edge case, and without that row you’ll text twice for one bounce. There are no X-RateLimit-* headers to tell you how close you are to the platform ceiling either, so the only thing standing between a bad deploy and a burst of duplicate texts is your own ledger.
The second is a deadline that reflects the alert, not the channel. A billing-run failure can wait 10 minutes for the email path to resolve; a fraud alert can’t wait 60 seconds. Store watch_until per alert type rather than hard-coding one timeout — a single global deadline is the tuning knob you’ll regret owning.
Suppression is the quiet third rule. A hard bounce should put the address on the email suppression list so the next alert doesn’t repeat the same wasted round trip, and the SMS side has its own list you can read the same way.
What the escalation costs
Email runs about $0.000115 per message and SMS about $0.007475 per message, both verified 2026-07-26 and marked approximate. The ratio is the durable part: a text costs roughly 65 email sends, which is exactly why the fallback should be conditional rather than a belt-and-braces “always do both”. Reads — the event feed, the suppression list, message status — are free but rate-limited, so the watcher itself adds nothing to the bill. New accounts get $2 of credit, about 267 texts.
Prices move down, and discounts run, so read today’s rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| node -e 'let raw = ""; process.stdin.on("data", (c) => (raw += c)).on("end", () => {
const doc = JSON.parse(raw);
for (const cap of (doc.data ?? doc).capabilities ?? []) {
if (["email.send", "sms.send"].includes(cap.id)) console.log(cap.id, cap.billing.price_usd, cap.billing.unit);
}
});'
Then confirm the address you keep bouncing is actually suppressed:
curl -sS "https://api.infrai.cc/v1/email/suppression/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Where a specialist pair wins
If your bounce volume justifies real-time webhooks with signed payloads, Twilio’s SendGrid event webhook plus Twilio SMS is the mature answer, and you should take it — Infrai doesn’t support delivery callbacks on either channel, so a 50-millisecond reaction to a bounce isn’t on the menu here. Sinch is the other serious two-channel vendor, with email and SMS under one commercial roof.
The case for keeping both channels on Infrai is narrower and, for a small team, usually decisive: the alert that generated the email, the queue that scheduled it, the cron job that runs this watcher, the error tracker that catches the watcher throwing, and the usage query that tells finance what tenant 4471’s alerting cost last month are the same account and the same bill. If you need per-tenant cost attribution across email and SMS, that’s a query here and a reconciliation project across two vendors.