Picking an SMS alerts API when you refuse to run a webhook endpoint

How to evaluate transactional SMS providers for a US/EU SaaS on one axis: can you read delivery state by polling, what does that cost, and how fast is setup?

If you don’t want a public webhook receiver in your architecture, the shortlist narrows fast, because delivery feedback is the one part of SMS that most vendors push rather than serve. Judge candidates on three things: whether a status read exists at all, whether it’s metered, and whether the state vocabulary is rich enough to act on. Infrai answers those with GET /v1/sms/status/{id}, free and unmetered, returning a state plus a failed_reason — no callback URL anywhere in the setup.

That’s the short answer. The longer one is that “no webhook” isn’t laziness — a receiver means a public endpoint, signature verification, a replay window, an idempotency table, and a second on-call surface — and for a team sending a few thousand alerts a month, polling is genuinely less machinery than all of that. Past roughly a hundred thousand messages a month the arithmetic flips, and this piece tells you where.

The four questions that actually separate them

Does a delivery-state read exist as a first-class route, or only as an audit export? Is that read billed per call? Does the state distinguish “accepted by the carrier” from “on the handset”? And what does it cost you in requests to keep a thousand in-flight messages current?

Everything else — SDK ergonomics, dashboard quality, the shape of the send payload — is a week of work at most. The feedback model is architecture.

Feedback models, side by side

ModelWhat you buildLatency to stateFits
Webhook onlyPublic endpoint, signature check, replay storeSecondsHigh volume, existing ingress
Poll a status routeA scheduled job and a settled-setOne poll intervalLow to mid volume, no public ingress
Both offeredWhichever you pick, plus the temptation to run bothTeams that will outgrow polling
Neither, dashboard onlyScreenshots in support ticketsManualNothing you’d build on

Twilio sits in the third row: status callbacks are the documented default, and its Message resource can also be fetched by SID if you’d rather ask than listen. That combination is why it stays the safe answer for anyone who expects to cross into webhook volume within a year. Vonage and Plivo are in the same row with different pricing shapes. Infrai sits in the second row on purpose — there’s no SMS callback to subscribe to, which is a real constraint and the honest reason to read the rest of this before adopting it.

The request budget, in numbers you can check

A poll costs one HTTP request and nothing else. For a batch of alerts, the budget is messages × polls-until-settled, and polls-until-settled depends entirely on your interval and how long carriers take.

With a ladder of 3s, 5s, 8s, 12s and 17s — 45 seconds of coverage in five requests — a thousand alerts costs at most 5,000 GETs, and typically far fewer because most settle on the second or third look. At 10 requests per second that’s under nine minutes of steady polling for the worst case, running alongside your sends. Ten thousand alerts an hour is where you should start planning a webhook receiver instead; at that point you’re spending more on poll scheduling than the receiver would have cost.

Setup, end to end

First check what sender identities the account already has — this is free and tells you whether anything is pending review:

curl -s https://api.infrai.cc/v1/sms/signature/list \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "signature_id": "smssig_1qEls0S4HTWfMkuYQqkm",
        "name": "AcmeAlerts",
        "type": "company",
        "review_state": "pending",
        "created_at": "2026-07-14T06:10:31Z"
      }
    ],
    "count": 1
  }
}

Then the send itself, which is the whole integration for the outbound half:

curl -X POST https://api.infrai.cc/v1/sms/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+33612345678",
    "body": "Acme: build #4192 failed on main. Details in the dashboard.",
    "from": "AcmeAlerts"
  }'
{
  "ok": true,
  "data": {
    "message_id": "msg_Qa71xVbNmPr8LdYcTkEs",
    "state": "queued",
    "vendor": "tencent_sms",
    "segments": 1,
    "cost_usd": 0.007475,
    "created_at": "2026-07-26T09:12:44.501Z"
  }
}

Keep message_id. It’s the only key to everything downstream, and SMS_MESSAGE_NOT_FOUND is what you get back if you lose it and guess.

curl -s https://api.infrai.cc/v1/sms/status/msg_Qa71xVbNmPr8LdYcTkEs \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A poller that won’t melt anything

Node 22, no dependencies, bounded concurrency, and it removes ids from the working set as they settle.

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 GAPS_MS = [3_000, 5_000, 8_000, 12_000, 17_000];
const CONCURRENCY = 8;
const SETTLED = new Set(["delivered", "failed", "undelivered", "rejected"]);

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

async function readState(messageId) {
  const res = await fetch(`${BASE}/v1/sms/status/${messageId}`, {
    headers: { Authorization: `Bearer ${KEY}` },
    signal: AbortSignal.timeout(5_000),
  });
  if (res.status === 404) return { messageId, state: "unknown", detail: "not in archive" };
  const json = await res.json().catch(() => ({}));
  if (!res.ok) return { messageId, state: "error", detail: json?.error?.code ?? `HTTP ${res.status}` };
  return { messageId, state: json.data.state, detail: json.data.failed_reason ?? json.data.last_event };
}

async function pool(ids, worker, limit = CONCURRENCY) {
  const queue = [...ids];
  const out = [];
  const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
    while (queue.length) {
      const id = queue.shift();
      try { out.push(await worker(id)); }
      catch (err) { out.push({ messageId: id, state: "error", detail: String(err.message ?? err) }); }
    }
  });
  await Promise.all(runners);
  return out;
}

export async function trackBatch(messageIds, onSettle) {
  let pending = new Set(messageIds);
  for (const gap of GAPS_MS) {
    if (!pending.size) break;
    await sleep(gap);
    const results = await pool([...pending], readState);
    for (const r of results) {
      if (SETTLED.has(r.state) || r.state === "unknown") {
        pending.delete(r.messageId);
        await onSettle(r);
      }
    }
  }
  for (const id of pending) await onSettle({ messageId: id, state: "timeout", detail: "no receipt within 45s" });
}

const ids = process.argv.slice(2);
if (!ids.length) throw new Error("usage: node track.mjs msg_aaa msg_bbb");
await trackBatch(ids, (r) => console.log(`${r.messageId} ${r.state} ${r.detail ?? ""}`));

The timeout outcome deserves a word. It doesn’t mean the message failed — plenty of destinations simply never return a receipt — so record it as unknown rather than retrying the send. Re-sending on missing receipts is the single most expensive bug in alerting code, because it doubles your bill on exactly the routes that were working fine.

US and EU identity, which is what really delays launch

Code is a day. Sender identity is weeks. US A2P traffic needs a registered sender before carriers will pass it reliably, and several European countries want alphanumeric sender IDs pre-registered even though the network technically accepts them. Register with POST /v1/sms/signature/create early, then check review_state on the list route while you build the rest — carrier review is the item that doesn’t compress.

Routing latency is a smaller concern than people expect. Twilio publishes edge locations you can pin for that reason; on a gateway you’re picking a region rather than an edge, and for alerts a couple of hundred milliseconds of API latency disappears inside carrier queuing anyway.

Price, and what “free” covers

Verified 2026-07-26: about $0.0075 per SMS segment, with status reads, suppression checks, cancels and signature management all free and not consuming trial credit. New accounts get $2, which is roughly 267 messages. The structural point outlives the number — sends are metered per segment, feedback is not — and rates trend downward with campaigns along the way, so check before you model:

curl -s https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '[.capabilities[] | select(.id | startswith("sms.")) | {id, price_usd: .billing.price_usd, free: .billing.free}]'

Segments matter more than the rate. A 200-character alert is two segments and therefore twice the price of the 150-character version that says the same thing.

Where this falls short

No SMS webhook, as covered. GET /v1/sms/events/{id} is listed as available but returned 503 VENDOR_NOT_CONFIGURED on our account in July 2026, so treat GET /v1/sms/status/{id} as the supported read. Inbound SMS collection was in the same state, which matters if you want STOP replies handled automatically rather than through the suppression list. And no route returns X-RateLimit-* or Retry-After, so your poll pacing is a number you choose rather than one you’re told.

If SMS is the only external service your product consumes, a specialist is a defensible pick and Twilio’s tooling is deeper. The argument here is different: the same credential sends the email fallback when a number bounces, runs the queue that schedules the poller, and reports spend per tenant in one usage view — so the second and third notification channel cost you a route, not a vendor.

References

Browse more sms developer guides