Simple SMS notifications for a web app: batch send, polling, suppressions
Run US and EU SMS alerts without a webhook endpoint: one batch call for up to 100 messages, free status polling, and a suppression list that survives STOP replies.
You don’t need a webhook endpoint to send SMS alerts from a web app. A batch call, a status poll on the handful of messages you care about, and a suppression list will carry a normal product for a long time — and Infrai’s SMS routes are shaped for exactly that, with one billable send and free reads around it.
That matters because a webhook is not one feature. It’s a public HTTPS endpoint, a signature check, a replay window, a retry policy, and something to do when your own service is the thing that’s down. For a few thousand alerts a month, polling is less code and fewer ways to be wrong.
What you give up by not receiving callbacks
Latency on the knowledge of delivery, and nothing else. The message still goes out at the same speed; you just learn its final state on your schedule instead of theirs.
| Polling | Webhook callbacks | |
|---|---|---|
| Infrastructure you own | None beyond your existing worker | Public endpoint, TLS, signature verification |
| Time to learn a failure | Your poll interval (30–60 s is plenty) | Seconds |
| Behaviour when your app is down | Nothing lost; poll later | Retries, then dropped events |
| Cost of the reads | Free, rate-limited | Free |
| Good fit for | Alerts, receipts, reminders | Two-way chat, live agent handoff |
Pick polling until a product requirement forces the other column.
One call, up to 100 messages
The batch route takes a messages array where each item is a full send request, plus an optional idempotency_key covering the whole batch. That key is the reason to use it even for two recipients — a retried batch after a socket timeout won’t double-send.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/sms/batch/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"to": "+14155550142", "body": "Deploy 4a91c finished. 0 failed checks.", "from": "InfraiOps"},
{"to": "+447700900123", "body": "Deploy 4a91c finished. 0 failed checks.", "from": "InfraiOps"}
],
"idempotency_key": "deploy-4a91c-notify"
}'
Results come back positionally — results[i] lines up with messages[i] — and each entry carries either a send result or an error, so a single bad number doesn’t sink the batch:
{
"ok": true,
"data": {
"results": [
{
"index": 0,
"result": {
"message_id": "sms_9Qb2mXcT4kR7",
"state": "queued",
"vendor": "tencent_sms",
"segments": 1,
"cost_usd": 0.007475,
"created_at": "2026-07-25T15:49:26Z"
},
"error": null
},
{
"index": 1,
"result": null,
"error": {"code": "SMS_SUPPRESSED_RECIPIENT", "message": "recipient is on the suppression list"}
}
]
}
}
The ceiling is 100 messages per call. Above that, chunk in your worker — and give each chunk its own idempotency key, or a retry of chunk three replays chunk one.
Poll only what matters
segments in that response is the billing unit, not the message count. A 200-character alert is two segments and costs twice as much, which is worth knowing before you paste a runbook URL into the body.
Status reads are free:
curl -sS "https://api.infrai.cc/v1/sms/status/sms_9Qb2mXcT4kR7" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"id": "sms_9Qb2mXcT4kR7",
"status": "delivered",
"found": true,
"vendor": "tencent_sms",
"to": "+14155550142",
"delivered_at": "2026-07-25T15:49:41Z",
"failed_reason": null
}
}
Worth flagging a rough edge: this record comes from the per-account sent-message archive, and the archive keys the lifecycle state as status while the fuller delivery-tracking fields (state, attempt, last_event) are optional and often absent. Read status first and fall back — the sample worker below does. When you need the whole timeline rather than the latest state, GET /v1/sms/events/{id} returns ordered events with type, at and recipient, plus a next_cursor for paging.
Suppressions are the part people skip
A US or EU user who replies STOP must stop receiving messages, and “we filtered it in application code” is not a defence when the same number gets a reminder from another code path. Keep the list at the gateway.
curl -sS -X POST "https://api.infrai.cc/v1/sms/suppression/check" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"phone": "+447700900123", "scope": "account"}'
A suppressed number comes back with suppressed: true plus reason, added_at and attempt_count_blocked — that last field is a quietly useful signal, because a number blocking dozens of attempts means some part of your app never got the message.
The whole worker, in Python 3
import os
import sys
import time
import requests
BASE = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
sys.exit("INFRAI_API_KEY is not set")
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
def unwrap(res):
payload = res.json()
if not res.ok or payload.get("ok") is False:
err = payload.get("error", {})
raise RuntimeError(f"{res.status_code} {err.get('code', 'UNKNOWN')}: {err.get('message', '')}")
return payload["data"]
def send_batch(alerts, key):
messages = [{"to": a["phone"], "body": a["text"], "from": "InfraiOps"} for a in alerts]
res = requests.post(
f"{BASE}/v1/sms/batch/send",
headers=HEADERS,
json={"messages": messages, "idempotency_key": key},
timeout=30,
)
return unwrap(res)["results"]
def wait_for_delivery(message_id, attempts=10, interval=30):
for _ in range(attempts):
res = requests.get(f"{BASE}/v1/sms/status/{message_id}", headers=HEADERS, timeout=15)
data = unwrap(res)
state = data.get("status") or data.get("state")
if state in ("delivered", "failed", "expired", "cancelled", "auto_suppressed"):
return state, data.get("failed_reason")
time.sleep(interval)
return "pending", None
if __name__ == "__main__":
alerts = [
{"phone": "+14155550142", "text": "Nightly export finished: 12,480 rows."},
{"phone": "+447700900123", "text": "Nightly export finished: 12,480 rows."},
]
for item in send_batch(alerts, key="nightly-export-2026-07-25"):
if item.get("error"):
print(f"[{item['index']}] rejected: {item['error']['code']}")
continue
mid = item["result"]["message_id"]
state, reason = wait_for_delivery(mid)
print(f"[{item['index']}] {mid} -> {state} {reason or ''}")
Ten attempts at 30-second intervals gives a message five minutes to settle, which is generous for a domestic delivery and short enough that a stuck job doesn’t run all night.
What it costs, and what stays free
Sending is the only billable step here. Verified 2026-07-25, sms.send and the batch route are $0.007475 per message, and status, events, cancel, suppression management and template management are all free but rate-limited. New accounts get $2 free credit, worth about 267 messages. Read your own numbers rather than trusting the ones above:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns a 30-day breakdown by capability with cost and call counts, so “which feature is spending my SMS budget” is a query rather than a guess. Per-message rates in this market keep drifting down, so what you read may be below what’s printed here.
Limits, and when to buy elsewhere
The SMS surface is western-region with tencent_sms as the ready vendor and Twilio pending, so a specific US short code, a number you own, or per-country routing you tune yourself are all reasons to stay with Twilio or Plivo. Inbound messages are the sharper limitation: GET /v1/sms/inbound/list needs a configured inbound-capable vendor and otherwise returns a typed error, so replies and two-way conversations aren’t something this surface does today. There’s also no scheduling UI, no per-user quiet hours and no digest engine — that’s your product logic.
What you get instead is that the cron job firing the worker, the queue holding the alert backlog, the error tracker catching a failed batch and the usage query attributing it all to one tenant sit on the same key and the same bill. For a small team that’s usually worth more than a tenth of a cent per message.