Multi-provider SMS failover without a second SDK or a second bill

What vendor failover actually requires, what a gateway gives you for free, and the cross-channel fallback that survives the outage a second SMS vendor won't.

Yes, but be precise about which outage you’re buying insurance against. A gateway like Infrai removes the parts you’re dreading — two SDKs, two sets of credentials, two invoices, two sender registrations — by putting a vendor pool behind one route and one key. What it can’t do is make itself immune, so the design that actually survives a bad day pairs vendor-level failover with a second channel you control.

Start by separating the three failures people lump together. A carrier rejecting your traffic is not the same as your provider’s API returning 503, and neither is the same as your provider being up but silently dropping messages to one country. Only the middle one is fixed by having a second vendor account; the first needs a different route to the same handset, and the third needs you to be watching delivery receipts at all.

What failover actually requires

Four pieces, and the SDK count is not one of them.

A second upstream that’s ready to serve — credentials loaded, sender identity approved, not merely “supported on the pricing page”. A health signal that doesn’t come from the thing that’s broken. A retry that’s safe to repeat, since a failover attempt after a timeout is the classic way to send one message twice. And a decision point in your code that can act in seconds rather than after a support ticket.

Read the first one straight from the API rather than trusting a marketing table:

export INFRAI_API_KEY="your_infrai_api_key"

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

Every capability in that document carries its own vendor state, and the fields are the honest ones:

{
  "id": "sms.send",
  "method": "POST",
  "path": "/v1/sms/send",
  "available": true,
  "regions": ["western"],
  "vendors": ["twilio", "tencent_sms"],
  "vendors_ready": ["tencent_sms"],
  "vendors_pending": ["twilio"],
  "key_status": "live",
  "default_vendor": "tencent_sms"
}

Worth flagging plainly, because it’s the answer to the question as asked: vendors lists what the route can route to, vendors_ready lists what it can route to right now. On our account today that’s one ready vendor with a second pending, so SMS vendor failover is a mechanism the platform has rather than a redundancy you inherit on signup. Check the field before you design around it, and re-check it after any credential change.

That check belongs in your deployment pipeline, not in a runbook nobody opens:

// vendor-preflight.mjs — Node 22 ESM. Fails the deploy if the pool is thinner than expected.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const MIN_READY = Number(process.env.MIN_READY_VENDORS ?? 1);

const res = await fetch("https://api.infrai.cc/v1/discovery", {
  headers: { authorization: `Bearer ${KEY}` },
  signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`discovery failed: HTTP ${res.status}`);

const doc = await res.json();
const caps = doc.data?.capabilities ?? doc.capabilities ?? [];
const sms = caps.find((c) => c.id === "sms.send");
if (!sms) throw new Error("sms.send is not present in discovery");

const ready = sms.vendors_ready ?? [];
console.log(`sms.send ready=${ready.join(",") || "none"} pending=${(sms.vendors_pending ?? []).join(",") || "none"}`);
if (ready.length < MIN_READY) {
  console.error(`FAIL only ${ready.length} ready vendor(s), expected >= ${MIN_READY}`);
  process.exit(1);
}

Three ways to buy redundancy

ApproachWhat breaks the two-SDK problemWhat it costs youWhere it still fails
Two vendor accounts, your own routernothing — you own both integrationstwo contracts, two sender registrations, two invoices, reconciliationyour router is now the single point, and it’s the least-tested code you own
One gateway with a vendor poolone key, one call shape, one billyou inherit the gateway’s availabilitygateway-wide incident; a pool with one ready vendor is not a pool
Gateway plus a second channelone key stilla second message type to write and testuser has no email either — genuinely rare

The third row is the one we’d recommend building first, because it’s cheap and it covers the failure the other two don’t: the message that the API accepted, billed, and never delivered.

Degrade to another channel, not to a retry loop

Retrying a send against the same upstream during an incident mostly manufactures duplicate charges. The useful move is to give the SMS a deadline, then switch channel — and because email lives on the same account and the same key, that’s one more fetch rather than one more vendor relationship.

// notify.mjs — Node 22 ESM. SMS first, email if it hasn't landed in time.
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function post(path, payload) {
  const res = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(10_000),
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw Object.assign(new Error(json.error?.message ?? `HTTP ${res.status}`), { code: json.error?.code });
  return json.data;
}

async function landed(messageId, deadlineMs) {
  const until = Date.now() + deadlineMs;
  while (Date.now() < until) {
    const res = await fetch(`${API}/v1/sms/status/${messageId}`, { headers: { authorization: `Bearer ${KEY}` } });
    if (res.ok) {
      const { data } = await res.json();
      if (data.state === "delivered") return true;
      if (data.state === "failed" || data.state === "undelivered") return false;
    }
    await sleep(5_000);
  }
  return false;
}

export async function notify({ phone, email, text, subject }) {
  try {
    const sent = await post("/v1/sms/send", { to: phone, body: text, from: "AcmeOps" });
    if (await landed(sent.message_id, 45_000)) return { channel: "sms", messageId: sent.message_id };
  } catch (err) {
    if (err.code === "SMS_NOT_CONFIGURED" || err.code === "VENDOR_DOWN") console.warn(`sms unavailable: ${err.message}`);
    else throw err;
  }
  const mail = await post("/v1/email/send", { to: email, from: "ops@example.com", subject, html: `<p>${text}</p>` });
  return { channel: "email", messageId: mail.message_id };
}

Forty-five seconds is a deliberate choice, not a default — long enough for a normal delivery receipt, short enough that an on-call engineer still gets paged inside a minute.

Watch the state, not the status page

A vendor incident shows up in your own delivery data before it shows up on anyone’s status page. Poll the messages you sent and count what never reached a terminal state:

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

Status reads are free, so a health probe that samples the last few hundred sends every minute costs nothing but requests. An id the account doesn’t own returns SMS_MESSAGE_NOT_FOUND with a 404, which keeps “not ours” clearly separate from “not yet delivered”.

The economics, and what one bill buys

Sends are metered; status reads, suppression checks and signature lookups aren’t. The channel asymmetry is what makes the fallback strategy affordable — an SMS was $0.007475 per message and an email $0.000115 per message when we checked on 2026-07-26, so degrading to email costs roughly a sixty-fifth of the message you couldn’t deliver. Both figures move, and they move down more often than up:

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

That response carries an affordable_uses_hint block with the current per-unit price for the routes your account uses, which is the fastest way to get today’s number without reading a pricing page. One account, one balance, one usage breakdown by capability — the reconciliation work of running two SMS vendors is the cost people forget to price.

When you should keep two vendors anyway

If SMS is the product — an alerting company, a 2FA vendor, anything where a delivery failure is a contractual event — run your own contracts with Twilio and Vonage and accept the integration tax. You’ll get per-carrier routing controls, dedicated short codes and a support relationship that a shared gateway can’t offer — Twilio’s Messaging Services exist precisely to pool sender identities and shape routing at that level — and at high volume the direct rates win. Plivo is worth a look for the same reason in price-sensitive bulk cases.

The catch is that hardly anyone actually needs that. For a product where SMS is one notification path among several, the honest failure budget says: one gateway, a verified second channel, delivery receipts you actually read, and a deploy-time check that your vendor pool is what you think it is.

References

Browse more sms developer guides