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 a text message. The event feed distinguishes them, and RFC 3463’s status classes are the vocabulary underneath most of what providers report.

Event on the feedWhat it meansFallback action
queued, sentAccepted by the vendor, in flightWait — nothing has failed
deliveredThe receiving MTA took itDone; cancel the watch
bounced (5.x.x, hard)Mailbox doesn’t exist, domain rejects youText the user, suppress the address
bounced (4.x.x, soft)Full mailbox, temporary deferWait one retry cycle first
complainedMarked as spamNever re-send to that address; text only if the alert is safety-critical
No terminal event by deadlineSilent drop or slow MTAText 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"}`;
  // 4xx means the request itself was wrong, so replaying it changes nothing.
  // Only 5xx earns another pass.
  const transient = res.status >= 500;
  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 line in that code repays a second look, and it’s deliberately boring. Retry on 5xx, never on 4xx: a recipient the platform won’t accept comes back as a 400 — INVALID_RECIPIENT on the email leg, INVALID_PHONE_NUMBER on the SMS leg — each carrying retryable: false in the error envelope. A permanently bad address therefore leaves the loop on the first pass instead of eating your whole backoff budget, and your alerting code needs no special-case string matching to get there.

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. On the email leg you get a second layer for nothing: POST /v1/email/send accepts an idempotency_key, and replaying the same key hands back the original message rather than dispatching a duplicate — so the alert id you already generated can double as the send guard.

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

Infrai meters the two sends in this loop and nothing else.

  • POST /v1/email/send — $0.00046 per email, verified 2026-07-27.
  • POST /v1/sms/send — $0.008395 per message, same reading.

Both are flagged approximate, because the vendor underneath can change. The ratio is the durable part — a text sits well over an order of magnitude above an email, which is exactly why the fallback has to be conditional rather than a belt-and-braces “always do both”. Reads are free and rate-limited (the event feed, the suppression list, message status), so the watcher itself adds nothing to the bill.

Rates 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.

References

Browse more sms developer guides