SMS first, email second: an escalation clock for urgent alerts
Node 22 pattern for urgent event notifications: send the text, poll delivery against a hard deadline, and fall back to email on silence rather than on error.
For an urgent event, send the text immediately, give it a hard delivery deadline, and fire the email when that deadline passes without a confirmed delivery — not the moment the send reports a failure. A reported failure is the easy case. On Infrai both channels answer to one key, so the escalation is POST /v1/sms/send, a poll loop on GET /v1/sms/status/{id}, then POST /v1/email/send if the clock runs out.
That last sentence hides the design decision that matters. Most fallback code escalates on failure, which means it only escalates when a carrier bothers to tell you something went wrong — and carriers frequently tell you nothing at all. Twilio documents the same reality on its own status-tracking page: for some destinations a final receipt never arrives, so a message sits at sent forever. If your fallback waits for failed, it will never run for exactly the users it was written to protect.
Three clocks, not one retry loop
There are three separate timers in a two-channel escalation and people tend to collapse them into one.
The request timeout is how long you’ll wait for POST /v1/sms/send to answer. Keep it short — 5 seconds is generous for an accept-and-queue call — because a slow accept doesn’t mean a slow delivery.
The delivery deadline is how long you’ll accept ambiguity before treating the SMS as a miss. For a paging-grade alert, 45 seconds is a reasonable ceiling; for a “your export finished” notice, 5 minutes is fine and saves you an email.
The acknowledgement deadline is a product decision, not an infrastructure one: how long until nobody has clicked the link and you escalate to a human. That one lives in your own database and we won’t cover it here.
Fire the text, keep the id
curl -X POST https://api.infrai.cc/v1/sms/send \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155550142",
"body": "PROD db-primary failover started at 02:14 UTC. Ack: https://status.example.com/a/8f21",
"from": "AlertOps"
}'
{
"ok": true,
"data": {
"message_id": "msg_7hQ2xLpVdKmAeR3sTnYb",
"state": "queued",
"vendor": "tencent_sms",
"segments": 2,
"cost_usd": 0.01495,
"created_at": "2026-07-26T02:14:03.118Z"
}
}
Two things in that response drive the rest of the flow. message_id is the only handle for delivery tracking, and segments is 2 because the body ran past the single-segment limit — the alert costs double before it has left the building. Shortening the acknowledgement URL is the cheapest optimisation available.
What the state actually tells you
Polling is one free GET against a concrete path:
curl -s https://api.infrai.cc/v1/sms/status/msg_7hQ2xLpVdKmAeR3sTnYb \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"message_id": "msg_7hQ2xLpVdKmAeR3sTnYb",
"state": "sent",
"vendor": "tencent_sms",
"attempt": 1,
"last_event": "carrier_accepted",
"delivered_at": null,
"failed_reason": null
}
}
state: "sent" with a null delivered_at is the ambiguous middle, and it’s where most alerts live at the 30-second mark. Here’s the decision table the worker implements:
| Last state at the deadline | What it means | Escalate to email? |
|---|---|---|
delivered | carrier confirmed handset receipt | No |
sent | accepted upstream, no receipt back | Yes — this is the silent-failure case |
queued | still inside the gateway | Yes, and cancel the send if it’s still pending |
failed / undelivered | terminal, with failed_reason populated | Yes, immediately — don’t wait for the clock |
| HTTP 404 on the id | wrong account, or the archive dropped it | Yes, and log the id for review |
Cancelling a still-queued message is free (POST /v1/sms/cancel/{id}), so a stand-down costs nothing but the round trip.
The escalation, in Node 22
No dependencies, ESM, and every failure path handled. Key the eventId in your own table before you call anything — this worker is the piece most likely to be retried by whatever scheduled it.
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is unset (use your_infrai_api_key locally)");
const HEADERS = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const DELIVERY_DEADLINE_MS = 45_000;
const POLL_GAPS_MS = [3_000, 5_000, 8_000, 12_000, 17_000];
const TERMINAL_BAD = new Set(["failed", "undelivered", "rejected"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function api(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: HEADERS,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(5_000),
});
const json = await res.json().catch(() => ({}));
if (res.ok) return json.data;
const message = json?.error?.message ?? `HTTP ${res.status}`;
throw Object.assign(new Error(message), {
status: res.status,
code: json?.error?.code,
// A 400 names bad input directly; mark it permanent so nothing retries it.
permanent: res.status < 500 || /e\.164|invalid|not supported|malformed/i.test(message),
});
}
async function waitForDelivery(messageId) {
const until = Date.now() + DELIVERY_DEADLINE_MS;
let last = "queued";
for (const gap of POLL_GAPS_MS) {
if (Date.now() >= until) break;
await sleep(gap);
try {
const status = await api("GET", `/v1/sms/status/${messageId}`);
last = status.state;
if (last === "delivered") return { confirmed: true, last };
if (TERMINAL_BAD.has(last)) return { confirmed: false, last, reason: status.failed_reason };
} catch (err) {
if (err.status === 404) return { confirmed: false, last: "unknown", reason: "id not found" };
}
}
return { confirmed: false, last, reason: "deadline elapsed without a receipt" };
}
export async function alert({ eventId, phone, email, headline, url }) {
const audit = { eventId, sms: null, email: null };
const { suppressed } = await api("POST", "/v1/sms/suppression/check", { phone });
if (!suppressed) {
try {
const sent = await api("POST", "/v1/sms/send", {
to: phone,
body: `${headline} Ack: ${url}`,
from: "AlertOps",
});
audit.sms = { id: sent.message_id, segments: sent.segments, ...(await waitForDelivery(sent.message_id)) };
} catch (err) {
audit.sms = { error: err.code ?? "send_failed", permanent: Boolean(err.permanent) };
}
} else {
audit.sms = { skipped: "suppressed" };
}
if (audit.sms?.confirmed) return audit;
const mail = await api("POST", "/v1/email/send", {
to: email,
from: "alerts@example.com",
subject: `[urgent] ${headline}`,
html: `<p>${headline}</p><p>We couldn't confirm the SMS reached you.</p><p><a href="${url}">Acknowledge</a></p>`,
});
audit.email = { id: mail.message_id, suppressed: mail.suppressed_recipients };
return audit;
}
const result = await alert({
eventId: "evt_2026_07_26_0214",
phone: "+14155550142",
email: "oncall@example.com",
headline: "PROD db-primary failover started 02:14 UTC.",
url: "https://status.example.com/a/8f21",
});
console.log(JSON.stringify(result, null, 2));
The suppression check before the send is worth the 51 ms it took in our testing. A number that has opted out swallows every alert silently, and the escalation would otherwise wait the full 45 seconds to discover nothing.
curl -X POST https://api.infrai.cc/v1/sms/suppression/check \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "phone": "+14155550142" }'
The retry rule that quietly double-sends
Bad input on this API arrives as a clean 400 with the reason named in message and retryable: false. A generic “retry every 5xx three times” wrapper therefore leaves it untouched — which is the point, because no wait fixes a malformed number, and a retry after the message already went out is a second send you pay for. That’s the reason the helper above marks any 4xx as permanent before deciding anything.
Verifying it landed, on both channels
curl -s "https://api.infrai.cc/v1/email/event/list?message_id=msg_FW89oeWakKVGOpIvXCEC7J5a" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns records[] with type and occurred_at per recipient — delivered, bounced, opened. The message_id query parameter is required; without it the route answers 400.
What a two-channel alert costs
Two metered writes, one section, both read on 2026-07-27 and both published as approximate: POST /v1/sms/send bills $0.008395 per message and POST /v1/email/send bills $0.00046 per email. The SMS figure is the one that really moves, because destination country moves it, and a two-segment alert bills as two messages. What’s durable is the ratio and the shape — text is more than an order of magnitude dearer than mail per recipient, which is exactly why the ladder starts with SMS and stops there when it works. Delivery polls, suppression checks and cancels are free, rate-limited routes, so the poll loop above adds nothing to the bill. A new account starts with $2 of trial credit. Read today’s numbers rather than trusting these, since rates drift downward and discount campaigns run:
curl -s https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id=="sms.send" or .id=="email.send") | {id, price_usd: .billing.price_usd, unit: .billing.unit}]'
Limits, and where a specialist wins
GET /v1/sms/events/{id} gives the full per-hop timeline, but GET /v1/sms/status/{id} carries the single current state the escalation clock actually reads — so build the poll loop on status and keep the event timeline for post-mortems. There are no X-RateLimit-* or Retry-After headers anywhere on these routes, which means your poll ladder is also your backoff policy. And there’s no webhook to subscribe to for SMS state, so a fleet of thousands of concurrent alerts will need its own poll scheduler rather than a free push feed.
If paging is your product — on-call rotations, escalation policies, voice fallback after SMS — Twilio’s Notify and Verify products or Vonage’s verification workflows carry more of that logic than a gateway does, and you’d be better off buying it. The argument for doing it here is that the same credential already covers the queue that schedules the retry, the storage bucket holding the incident timeline, and the usage view that attributes the spend per tenant, so the second channel isn’t a second vendor, a second contract and a second invoice.