SMS OTP never arrived: choosing the simplest backup channel

Score login backup channels by setup cost rather than delivery rate, and wire an email-code fallback behind Infrai's managed SMS OTP with Node 22.

For most SaaS logins the cheapest usable backup is an email code, and the reason is setup cost rather than delivery physics: it needs no carrier registration, no sender approval, no second vendor relationship. On Infrai you already have the route — the same key that runs POST /v1/sms/otp runs POST /v1/email/send — so the fallback is a branch in your controller rather than a procurement exercise.

The part nobody mentions is that Infrai’s managed OTP store is SMS-only. There’s no email-code equivalent behind the same verify route, so on the fallback path you generate, hash, store and expire the code yourself. That’s maybe forty lines. It’s also the honest limitation you should weigh before choosing this over a verification product that handles every channel for you.

Score the candidates on what it costs to add

Backup channelWhat adding it costsWho it still failsOn Infrai today
Email codeOne route you already have; you own code storageUsers whose email is the account they’ve lostPOST /v1/email/send
Voice call (code read aloud)A new vendor, a new number, per-country rulesNoisy environments, VoIP blocksNot available
WhatsApp / RCSBusiness account verification, template approvalUsers without the appNot available
TOTP authenticator appEnrolment UI, recovery flow, QR generationUsers who never enrolled before the failureSelf-built
Pre-issued backup codesPrint/download UI plus secure storageUsers who lost the codesSelf-built
A second SMS vendorAnother account, another sender registrationEverything SMS already fails atVendor pinning per account

Read that table twice before optimising for delivery rate. A backup channel that takes six weeks of carrier paperwork isn’t a backup, it’s a roadmap item — and the failure you’re covering happens at 11pm tonight to someone who can’t get into their account.

Email wins on availability at the moment of failure. It loses badly in one specific case, which is the user whose email account is itself the thing they can’t reach.

Three different failures wear the same T-shirt

“SMS OTP failed” collapses three situations that deserve different handling.

The first is a rejected send: the API told you immediately that it wouldn’t go. The second is a silent non-delivery — accepted, queued, never landed on the handset, no error anywhere. The third is delivery followed by a user typing the wrong digits.

Only the first two justify switching channel. The third justifies a retry counter and, past three attempts, a lockout.

Here’s the wrinkle in the first case. A recipient that isn’t in E.164 form comes back as HTTP 503 VENDOR_DOWN with retryable: true, and the actual reason lives only in message. Client code that retries every 5xx will loop on a phone number that will never be valid, and your user waits for a code that was never sendable. Parse the message before you trust the flag.

The managed loop, and exactly where it stops

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/sms/otp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155550142", "template": "login_code_en"}'
{
  "ok": true,
  "data": {
    "request_id": "otpreq_c41d8ba207",
    "sent": true
  }
}

The gateway generates the digits, stores them, expires them and counts attempts. You never see the code, which is the point — there’s nothing in your logs to leak.

curl -sS -X POST "https://api.infrai.cc/v1/sms/verify" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155550142", "code": "482915"}'
{
  "ok": true,
  "data": {
    "verified": false,
    "reason": "no_code_issued",
    "to": "+14155550142"
  }
}

That response is worth studying, because it’s the one your fallback path will produce by accident. Submitting a code for a number that never received one bills $0.005 per call and answers verified: false with reason: "no_code_issued" — a successful HTTP call that charges you for a question with no answer. Gate the verify endpoint on your own record that a code was actually issued, or a bored script can run up a bill one half-cent at a time.

The email fallback, in Node 22

// otp-fallback.mjs — Node 22 ESM
import { randomInt, createHash, timingSafeEqual } from "node:crypto";

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const TTL_MS = 10 * 60_000;
const MAX_ATTEMPTS = 3;
// userId -> { digest, expiresAt, attempts }. Redis or Postgres in a real deployment.
const pending = new Map();

const digest = (code, salt) => createHash("sha256").update(`${salt}:${code}`).digest();

async function post(path, body) {
  const res = await fetch(BASE + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const payload = await res.json().catch(() => ({}));
  if (res.ok && payload.ok !== false) return payload.data ?? payload;
  const err = payload.error ?? {};
  throw new Error(`${err.code ?? "HTTP_" + res.status}: ${err.message ?? "request failed"}`);
}

export async function startSmsChallenge(userId, phone) {
  const data = await post("/v1/sms/otp", { to: phone, template: "login_code_en" });
  pending.set(userId, { channel: "sms", phone, issuedAt: Date.now(), attempts: 0 });
  return { channel: "sms", requestId: data.request_id, sent: data.sent };
}

export async function fallbackToEmail(userId, email) {
  const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
  const salt = randomInt(0, 2 ** 31).toString(36);
  pending.set(userId, { channel: "email", digest: digest(code, salt), salt, expiresAt: Date.now() + TTL_MS, attempts: 0 });
  await post("/v1/email/send", {
    to: email,
    from: "login@yourdomain.com",
    subject: "Your sign-in code",
    html: `<p>Your code is <strong>${code}</strong>. It expires in 10 minutes.</p>`,
  });
  return { channel: "email", expiresInSec: TTL_MS / 1000 };
}

export async function submit(userId, phone, entered) {
  const record = pending.get(userId);
  if (!record) return { ok: false, reason: "no_challenge" };
  record.attempts += 1;
  if (record.attempts > MAX_ATTEMPTS) { pending.delete(userId); return { ok: false, reason: "locked" }; }

  if (record.channel === "sms") {
    const data = await post("/v1/sms/verify", { to: phone, code: entered });
    if (data.verified) pending.delete(userId);
    return { ok: Boolean(data.verified), reason: data.reason ?? null };
  }
  if (Date.now() > record.expiresAt) { pending.delete(userId); return { ok: false, reason: "expired" }; }
  const candidate = digest(entered, record.salt);
  const match = candidate.length === record.digest.length && timingSafeEqual(candidate, record.digest);
  if (match) pending.delete(userId);
  return { ok: match, reason: match ? null : "wrong_code" };
}

const user = "usr_7719";
console.log(await startSmsChallenge(user, "+14155550142"));
console.log(await fallbackToEmail(user, "person@example.com"));
console.log(await submit(user, "+14155550142", "000000"));

Two things in there aren’t optional. timingSafeEqual on the digest, because a plain === on a six-digit code leaks position information to anyone patient. And pending.delete on success, so a code can’t be replayed — the SMS path gets that for free from the gateway, the email path only gets it because you wrote the line.

Offer the fallback button after about 45 seconds, not immediately. Most texts that land, land inside 10 seconds; a button offered at second zero trains users to skip the channel you’re paying to keep.

What the two paths cost

An SMS OTP send is roughly $0.007475 per message and a verify call $0.005, both verified 2026-07-26; the email send is about $0.000115, so the fallback path costs about 1.5% of the SMS path. Free routes — status, events, suppression and signature reads — don’t touch the balance, and new accounts start with $2 of credit, about 267 texts or 399 verifies. Rates here move downward over time and discount campaigns run, so treat those figures as a snapshot and read the live ones:

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

Before you blame the channel, check that your sender registration is actually approved — an unapproved signature is a very common cause of “the SMS never arrived”:

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

When to buy a verification product instead

Twilio Verify covers SMS, voice, WhatsApp and email behind one API with its own fallback orchestration, and if you want a second channel without writing the code-storage half yourself, that’s the straightforward purchase. Vonage Verify does the same trick with a built-in escalation sequence across channels. Both cost more per verified user than a raw send, and both are another account to hold.

Infrai’s pitch here isn’t cheapness, it’s that the login codes, the transactional email, the session store, the rate-limit counters and the error tracker sit behind one key — and the month-end question “what did authentication cost tenant 4471” is a query rather than a spreadsheet.

References

Browse more sms developer guides