Choosing an SMS API for outage paging: poll, resend, stand down

What an on-call paging channel needs from an SMS API — an addressable message id, readable delivery state, a free cancel — with runnable Node 22 code on Infrai.

An outage pager isn’t a notification feature, it’s a control loop: send, confirm the handset really got it, escalate when it didn’t, stop paging once the incident closes. So judge candidate APIs on two things before price — does the send hand back an id you can address later, and can you read delivery state on demand? Infrai answers yes to both, via POST /v1/sms/send and GET /v1/sms/status/{id}.

There’s no webhook subscription to register on Infrai’s SMS surface, and no Retry-After header on a throttled response either, so every timing decision below is one your own code makes on a clock it owns. That’s a real constraint, and for paging it turns out to be a mild one — you’re polling a handful of messages, not reconciling a million-message campaign.

The four properties that separate a pager from a notifier

PropertyWhy paging needs itRoute
Addressable message idEscalation is stateful; you need to ask about this pagePOST /v1/sms/sendmessage_id
Delivery state on demandThe alert path can’t depend on a callback into the system that’s downGET /v1/sms/status/{id}
Event timelinePost-incident review needs when it queued, when the carrier took itGET /v1/sms/events/{id}
Pull-back before dispatchThe page you queued 4 seconds ago should not wake anyone at 03:00 if the incident just auto-resolvedPOST /v1/sms/cancel/{id}

The last row is the one most comparisons skip. Cancel is free and rate-limited on Infrai; a resend costs a full message. That asymmetry should shape your ladder — cancel liberally, resend deliberately.

Send, and keep the id

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155550142",
    "body": "SEV1 checkout-api: 5xx above 10% for 3 min. Ack in PagerHQ.",
    "from": "+14155550100"
  }'
{
  "ok": true,
  "data": {
    "message_id": "sms_9fT4kb2Qx7Ra",
    "state": "queued",
    "vendor": "tencent_sms",
    "segments": 1,
    "cost_usd": 0.007475,
    "created_at": "2026-07-26T02:14:08Z"
  }
}

Two fields there deserve attention. segments is how many billable parts your text became — a 3-segment page costs three times a 1-segment page, and alert text written by a template that interpolates a stack trace crosses that line quietly. state: "queued" means the gateway accepted it, not that a phone buzzed.

Poll instead of waiting

Delivery state is a plain GET against the id you kept.

curl -sS "https://api.infrai.cc/v1/sms/status/sms_9fT4kb2Qx7Ra" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "message_id": "sms_9fT4kb2Qx7Ra",
    "state": "failed",
    "vendor": "tencent_sms",
    "attempt": 1,
    "last_event": "carrier_rejected",
    "delivered_at": null,
    "failed_reason": "handset_unreachable"
  }
}

Poll every 5 seconds for the first minute, then back off. A domestic US or EU message that’s going to land usually lands inside 10 seconds; one still queued at 60 seconds is telling you something. The status read is free, so the cost of polling is your own request budget and the platform rate limit behind SMS_RATE_LIMIT — which, with no rate-limit headers exposed, you discover by hitting it.

The retry trap that pages nobody

Here’s the failure mode worth wiring around before you ship. Input-validation problems on the send route come back through the vendor channel, not as a 4xx:

{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "message": "recipient must be E.164 (got '4155550142')",
    "retryable": true,
    "http_status": 503
  }
}

A generic “retry on 5xx with backoff” client will retry that forever, and the page never goes out — during an incident, silently. Read message before you trust retryable, and treat anything naming the recipient, the body or the sender as permanent. The same shape shows up on POST /v1/email/send, so if you keep a shared HTTP helper, fix it once there.

The escalation ladder, in Node 22

No SDK, no dependencies. This sends to the primary phone, watches for a delivered state until a deadline, then walks to the secondary — and refuses to burn attempts on an input error.

// page-oncall.mjs — Node 22 ESM
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const LADDER = ["+14155550142", "+14155550187"]; // primary, then secondary
const DELIVERY_DEADLINE_MS = 90_000;
const POLL_EVERY_MS = 5_000;

class PermanentError extends Error {}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function call(path, init = {}) {
  const res = await fetch(API + 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 detail = `${err.code ?? "HTTP_" + res.status} ${err.message ?? ""}`.trim();
  // 503 + a message naming the input is a permanent problem wearing a 5xx.
  if (/e\.164|invalid|malformed|recipient|sender/i.test(err.message ?? "")) throw new PermanentError(detail);
  if (res.status === 429 || res.status >= 500) throw new Error(detail);
  throw new PermanentError(detail);
}

async function pageOnce(to, text) {
  const sent = await call("/v1/sms/send", {
    method: "POST",
    body: JSON.stringify({ to, body: text, from: "+14155550100" }),
  });
  const deadline = Date.now() + DELIVERY_DEADLINE_MS;
  let state = sent.state;
  while (Date.now() < deadline) {
    await sleep(POLL_EVERY_MS);
    const status = await call(`/v1/sms/status/${sent.message_id}`);
    state = status.state;
    if (state === "delivered") return { ok: true, id: sent.message_id, state };
    if (state === "failed") return { ok: false, id: sent.message_id, state, reason: status.failed_reason };
  }
  return { ok: false, id: sent.message_id, state, reason: "deadline_exceeded" };
}

export async function page(text) {
  const attempts = [];
  for (const number of LADDER) {
    try {
      const result = await pageOnce(number, text);
      attempts.push({ number, ...result });
      if (result.ok) return { paged: number, attempts };
    } catch (error) {
      const permanent = error instanceof PermanentError;
      attempts.push({ number, ok: false, reason: error.message, permanent });
      if (!permanent) throw error;
    }
  }
  return { paged: null, attempts };
}

console.log(JSON.stringify(await page("SEV1 checkout-api 5xx above 10% for 3 min"), null, 2));

Run it with INFRAI_API_KEY=your_infrai_api_key node page-oncall.mjs. Note what the ladder does not do: it never resends to a number that already failed with handset_unreachable, because a second identical message to a phone that’s off is a second charge for the same silence. Escalate sideways, not repeatedly.

Standing down

When the incident auto-resolves before anyone acks, cancel whatever’s still queued.

curl -sS -X POST "https://api.infrai.cc/v1/sms/cancel/sms_9fT4kb2Qx7Ra" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The window is small — often under a second on a domestic route — so treat it as a best-effort saving rather than a guarantee. An id the gateway no longer holds answers SMS_MESSAGE_NOT_FOUND instead of pretending.

What a page costs, and how to check today’s number

A send is about $0.007475 per message, verified 2026-07-26 and flagged approximate because the rate varies by destination and vendor; a resend bills the same as a fresh send; status, events and cancel reads are free but rate-limited. New accounts start with $2 of credit, roughly 267 messages. So a two-rung ladder with a 90-second deadline costs under two cents per incident, and the polling is free.

Rates drift downward and discount campaigns run, so pull the live figure rather than trusting a paragraph:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c 'import json, sys
doc = json.load(sys.stdin)
for cap in doc.get("data", doc).get("capabilities", []):
    if cap["id"] in ("sms.send", "sms.resend", "sms.status", "sms.cancel"):
        print(cap["id"], cap["billing"].get("price_usd", "free"), cap["billing"]["unit"])'

When Twilio or Vonage is the better buy

NeedBetter pickWhy
Voice call escalation after SMS failsTwilioProgrammable Voice sits behind the same account; Infrai’s SMS surface doesn’t support voice
Reply “ACK” to acknowledge a pageTwilio or VonageInfrai’s inbound route needs an inbound-capable vendor configured and answers VENDOR_NOT_CONFIGURED without one
Per-country sender rules you tune yourselfVonageFine-grained sender registration and routing controls
One key that also carries queues, cron, error tracking and emailInfraiThe alert path and the systems that generate alerts share an account

That last row is the argument, and it isn’t about the rate. A pager needs somewhere to queue the page, a cron job to test the channel weekly, an error tracker to notice when the pager itself throws, and a usage query at month end to attribute alerting spend per tenant — on Infrai, that’s the same key and one bill, not four vendor accounts with four rotation schedules.

Free reads make the weekly channel test cheap. This one is concrete enough to paste:

curl -sS "https://api.infrai.cc/v1/sms/suppression/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

If a responder’s number has landed on the suppression list — an opt-out keyword replied to some other message, months ago — you’d be better off finding out on a Tuesday than at 03:00 during a SEV1.

References

Browse more sms developer guides