Email or SMS for SaaS alerts: what a delivered notification costs
A delivered SMS costs well over an order of magnitude more than an email. The routing rule that follows, the live price lookup, and runnable send-and-track code for both.
For SaaS alerting, email is the default and SMS is the exception you pay for on purpose. On Infrai both live behind the same key: POST /v1/email/send bills per email, POST /v1/sms/send bills per message, and the gap between the two units is well over an order of magnitude. So the question worth answering isn’t which vendor prints the smallest number — it’s which of your events actually deserves a phone.
Get that split right and the provider choice stops mattering much. Get it wrong and no discount saves you, because you’re paying carrier rates to tell someone their weekly report is ready.
The two units you’re really comparing
Email is priced per message with essentially no floor. SMS is priced per segment — 160 GSM-7 characters, or 70 if you slip a single emoji or curly quote into the body — and in the US the base rate is only part of the bill, because carrier surcharges are added on top of whatever your provider charges.
That surcharge is the part comparison tables tend to drop. The US carriers levy a per-message fee that your provider passes through on a separate invoice line, so the base rate on a pricing page is usually only about two thirds of what a delivered message actually costs you. Twilio documents both halves openly, which is why its pricing page is the one worth reading before you model anything — just don’t stop at the first number on it.
Infrai quotes a single final per-call figure instead. Today sms.send reads $0.008395 per message and email.send reads $0.00046 per email, with no separate surcharge line. Rather than trusting either number six months from now, read them yourself — every route carries its own billing block in the discovery document:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-o discovery.json
node --input-type=module -e '
import { readFileSync } from "node:fs";
const doc = JSON.parse(readFileSync("discovery.json", "utf8"));
const caps = doc.data?.capabilities ?? doc.capabilities ?? [];
for (const c of caps) {
if (c.id === "sms.send" || c.id === "email.send") {
console.log(c.id, c.billing.price_usd, c.billing.unit);
}
}'
New accounts start with $2 of free credit, which goes roughly an order of magnitude further on email than on SMS. Divide it by whatever the call above returns; don’t trust a number someone else computed for you months ago.
Everything else about the two channels differs too, and most of it matters more than the rate:
Email (email.send) | SMS (sms.send) | |
|---|---|---|
| Billed unit | per email | per message, and long bodies split into segments |
| Character budget | effectively none | 160 GSM-7, or 70 once an emoji or curly quote sneaks in |
| Proof of delivery | GET /v1/email/get/{id} plus the event timeline | GET /v1/sms/status/{id} with failed_reason |
| Typical failure | suppression list, spam folder | carrier filtering, unregistered sender ID |
| Right for | everything that can wait an hour | the pages you’d wake someone up for |
The email column is where per-message pricing stops being the interesting variable at all. At 10,000 alerts a month you’re arguing over single-digit dollars whichever provider you pick, so spend the decision on deliverability and on what happens after the send.
Send on both channels with one credential
Email first. The documented body is to, from, subject and html:
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "oncall@example.com",
"from": "alerts@yourdomain.com",
"subject": "Payment webhook backlog above threshold",
"html": "<p>Queue depth 4,120 for 12 minutes. Runbook: https://example.com/runbooks/webhooks</p>"
}'
{
"ok": true,
"data": {
"message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp",
"from_used": "alerts@yourdomain.com",
"mode": "domain",
"accepted_recipients": ["oncall@example.com"],
"suppressed_recipients": []
}
}
Watch suppressed_recipients. An address that bounced or complained earlier is held back at send time and listed there rather than in accepted_recipients — so if your only alerting path is email to one person who unsubscribed last quarter, the send still returns 200 and nobody gets paged. Read the array, don’t assume it’s empty.
SMS takes to, body and from:
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 payments API 5xx above 10 percent. Ack in the console.",
"from": "InfraiAlerts"
}'
{
"ok": true,
"data": {
"message_id": "sms_9Qb2mXcT4kR7",
"state": "queued",
"vendor": "tencent_sms",
"segments": 1,
"cost_usd": 0.008395,
"created_at": 1784939403.45
}
}
segments and cost_usd come back on every send, so the cost of a bad template is visible immediately rather than at the end of the month. A 200-character alert is two segments and twice the price — trimming the runbook URL to a short link is a real saving at volume.
The routing rule, in code
Severity decides the channel; everything else is noise. This is Node 22 ESM, no dependencies:
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BASE = "https://api.infrai.cc";
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function post(path, body) {
const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
const payload = await res.json();
if (!res.ok || payload.ok === false) {
const err = payload.error ?? {};
throw new Error(`${path} failed: ${err.code ?? res.status} ${err.message ?? ""}`);
}
return payload.data;
}
export async function notify(event) {
const summary = `${event.service}: ${event.title}`;
if (event.severity === "sev1" && event.phone) {
return post("/v1/sms/send", {
to: event.phone,
body: summary.slice(0, 150),
from: "InfraiAlerts",
});
}
return post("/v1/email/send", {
to: event.email,
from: "alerts@yourdomain.com",
subject: summary,
html: `<p>${event.detail}</p><p><a href="${event.runbook}">Runbook</a></p>`,
});
}
One phone-call-shaped event per incident, everything else as email. Keep sev1 down around a couple of percent of your alert volume and your blended cost per notification sits far nearer the email rate than the SMS one — which is the entire argument for routing by severity instead of shopping for a channel.
Confirm it landed, then confirm what you spent
Both channels expose a free read path. GET /v1/email/list is concrete and needs no id, so it’s the fastest proof your integration is alive:
curl -sS "https://api.infrai.cc/v1/email/list?limit=5" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Per-message detail needs the id from the send response — GET /v1/email/get/{id} returns state and per-recipient state, GET /v1/sms/status/{id} returns state plus failed_reason when a carrier rejects. Status reads, event timelines and suppression management are all free and rate-limited, so polling a delivery costs nothing but your own request budget.
The limitations, stated plainly
SMS here is a western-region surface with tencent_sms as the ready vendor. If your requirement is a specific US short code, or a carrier relationship and a number you already own, buy it direct from Twilio and keep the number under your own account — that’s a legitimate reason to run a second vendor and nothing on this page changes it. Two-way conversations aren’t what this surface is for either. And there’s no bundled notification-preference UI, no digest engine, no per-user quiet hours; that’s product logic you write.
Postmark is the other honest answer, on the email side. Buy it if separating transactional streams from broadcast streams is a first-class requirement for you — its stream model is deeper than anything a general platform ships, and paying for that separately is a defensible call.
The thing a single-point tool can’t match is the second question, not the first. Debouncing duplicate alerts with POST /v1/queue/publish, capturing the exception that raised the event, sweeping stale incidents on a schedule, and then attributing the whole lot to a tenant through GET /v1/account/usage all run on the same key that sent the message. No second account, no second key rotation, no reconciliation spreadsheet at the end of the month.