One provider for email and SMS alerts, or two specialists?
The integration surface a second notification vendor adds, what consolidation is worth for a small team, and a runnable multi-channel dispatcher on Infrai.
For a startup shipping event notifications in the US and EU, run both channels through one gateway until a named requirement forces you off it. The per-message rates across serious providers land within a fraction of a cent of each other, so the money isn’t in the rate — it’s in the second integration, the second on-call runbook and the second invoice. Infrai puts SMS and email behind one credential and one REST shape, which is the specific thing that stops a notification stack growing a second copy of everything.
The counter-argument is real and worth stating up front: a specialist you already trust, with a warmed sending domain or a short code on your own paper, is not something to trade away for tidiness.
What the second vendor costs that isn’t per message
Count the artefacts, not the invoices. Every notification vendor you add brings a client library with its own release cadence, a key that needs rotating, an error taxonomy your retry logic has to learn, a status page someone has to watch, and a webhook signature scheme with its own replay window.
| Two specialists | One gateway | |
|---|---|---|
| Credentials to rotate | 2, on different schedules | 1 |
| Error shapes your retry code parses | 2 taxonomies | 1 envelope, one error.code |
| Client libraries to keep current | 2, plus transitive deps | None — plain HTTPS |
| Invoices to reconcile monthly | 2 | 1 |
| Per-capability spend view | Two dashboards, exported and joined | One usage query |
| Sandbox / test setup | 2 | 1 |
| Failure blast radius | Independent — one channel survives | Shared — gateway outage hits both |
That last row is the honest trade-off. Two vendors give you genuine channel independence, and if your SMS alerts exist precisely because email is down, splitting them is defensible engineering rather than sentiment.
For everyone else the shared row wins on effort.
One send shape, whichever channel fires
The SMS route takes to, body and an optional from sender label:
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": "+14155550173",
"body": "Invoice INV-4821 failed to charge. Retry queued for 09:00 UTC.",
"from": "AcmeOps"
}'
{
"ok": true,
"data": {
"message_id": "sms_7Kt4pQmR2vXb",
"state": "queued",
"vendor": "tencent_sms",
"segments": 1,
"cost_usd": 0.007475,
"created_at": "2026-07-26T09:14:02Z"
}
}
segments is the billing unit rather than the message count, so a 220-character alert is two segments at twice the price. Trimming a runbook URL is a real saving once you’re sending thousands.
Confirming delivery is a free read against the id you just got back:
curl -sS "https://api.infrai.cc/v1/sms/status/sms_7Kt4pQmR2vXb" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"id": "sms_7Kt4pQmR2vXb",
"status": "delivered",
"found": true,
"vendor": "tencent_sms",
"to": "+14155550173",
"delivered_at": "2026-07-26T09:14:19Z",
"failed_reason": null
}
}
An id that isn’t in your account’s archive answers SMS_MESSAGE_NOT_FOUND rather than a bare 404 body, which makes a mistyped id easy to tell apart from a route problem.
The email module answers on the same host, with the same bearer token and the same {ok, data, error} envelope — the reason that matters is visible in the next block, where one helper function serves both channels.
The dispatcher, in TypeScript
This is the whole integration layer for a two-channel notification stack. One auth path, one error taxonomy, one place to add a retry.
// notify.ts — Node 22, no dependencies
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
type Envelope<T> = { ok: boolean; data?: T; error?: { code: string; message: string } };
type SendResult = { message_id: string; state: string; segments?: number; cost_usd?: number };
async function call<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: body === undefined ? "GET" : "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = (await res.json()) as Envelope<T>;
if (!res.ok || payload.ok === false || !payload.data) {
const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw Object.assign(new Error(`${path}: ${err.code} ${err.message}`), { code: err.code });
}
return payload.data;
}
export interface Alert {
phone?: string;
headline: string;
detail: string;
}
const TRANSIENT = new Set(["SMS_RATE_LIMIT", "VENDOR_TIMEOUT", "VENDOR_UNAVAILABLE"]);
export async function sendAlert(alert: Alert, attempt = 0): Promise<SendResult | null> {
if (!alert.phone) return null;
try {
return await call<SendResult>("/v1/sms/send", {
to: alert.phone,
body: `${alert.headline} — ${alert.detail}`.slice(0, 150),
from: "AcmeOps",
});
} catch (err) {
const code = (err as { code?: string }).code ?? "";
if (TRANSIENT.has(code) && attempt < 3) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
return sendAlert(alert, attempt + 1);
}
throw err;
}
}
const result = await sendAlert({
phone: process.argv[2] ?? "+14155550173",
headline: "Charge failed",
detail: "INV-4821, retry at 09:00 UTC",
});
console.log(JSON.stringify(result, null, 2));
Adding the email channel to that file is one more call() with a different path and body. Adding a second vendor instead means a second call(), a second env var, a second error map and a second set of transient codes — none of it hard, all of it forever.
One bill, and where the money went
Reads are free here. Sends are the only billable step, at $0.007475 per SMS message, verified 2026-07-26 and marked approximate because the vendor mix underneath can move. Status polling, event timelines, suppression management and template management are all free but rate-limited, and new accounts get $2 of credit, worth about 267 messages.
Don’t take the figure on faith — the usage endpoint reports what you actually spent, broken down per capability:
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"period": "30d",
"total_cost": 9.43290581,
"total_calls": 17857,
"breakdown": [
{ "key": "sms.send", "label": "sms.send", "cost": 0.0299, "calls": 4, "failed_calls": 0 },
{ "key": "email.send", "label": "email.send", "cost": 0.0138, "calls": 120, "failed_calls": 0 }
]
}
}
Two channels, one array. Doing the same across two vendors means two CSV exports and a join that someone maintains by hand — and in a multi-tenant product, where you want spend per customer rather than per channel, the usual answer is one API key per tenant so the split falls out of the data instead of a spreadsheet. Rates in this market drift downward and discount campaigns run, so the figure that call prints may well be below the one quoted here.
When to keep two vendors anyway
Three cases, and they’re all specific.
You already have a warmed sending domain and a deliverability engineer who knows your bounce curve; moving that is a risk with no upside. You need a US short code, per-country routing rules you tune yourself, or a carrier relationship in your own name — you’d be better off with Twilio or Sinch directly, and keeping the number under your account. Or you need inbound messages: Infrai’s SMS surface doesn’t support receiving replies without a configured inbound-capable vendor, so two-way conversations aren’t something this stack does today.
Worth flagging one more limit before you commit: SMS here is western-region, with tencent_sms ready and Twilio still pending, so provider-level failover is thinner than at a vendor whose whole business is carrier routing. Vonage and MessageBird both give you more knobs there.
What you get in exchange is that the queue holding the alert backlog, the cron sweep that expires stale incidents, the object store keeping the incident snapshot, the error tracker that raised the event in the first place and the usage query attributing all of it are the same account, the same key and the same bill. For a team of five, that’s usually worth more than a fraction of a cent per message.