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 when the SMS returns an error. Errors are 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 deadlineWhat it meansEscalate to email?
deliveredcarrier confirmed handset receiptNo
sentaccepted upstream, no receipt backYes — this is the silent-failure case
queuedstill inside the gatewayYes, and cancel the send if it’s still pending
failed / undeliveredterminal, with failed_reason populatedYes, immediately — don’t wait for the clock
HTTP 404 on the idwrong account, or the archive dropped itYes, 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 503 carrying an E.164 complaint is bad input wearing a vendor costume.
    permanent: /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 doesn’t arrive as a 400. A recipient that isn’t in E.164 comes back as HTTP 503 VENDOR_DOWN with retryable: true and the real reason only in the human-readable message. A generic “retry every 5xx three times” wrapper will therefore hammer a number it can never reach, and — in the case where the 503 lands after the message actually went out — send the same alert twice. That’s the reason the helper above derives permanent from the message text 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

Verified 2026-07-26: an SMS segment runs about $0.0075 and a transactional email about $0.000115, so a single-segment text plus a fallback email is roughly $0.0076 — the email is rounding error, and the two-segment alert above costs twice as much as the one-segment version. Delivery polls, suppression checks and cancels are free. New accounts start with $2 in credit, good for a couple of hundred messages. 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} is documented as live but answered 503 VENDOR_NOT_CONFIGURED against our account in July 2026, so build the poll loop on GET /v1/sms/status/{id} and treat the event timeline as a bonus. 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.

References

Browse more sms developer guides