SMS OTP vs email code: what a verified login actually costs

A cost-per-verified-login model for US and EU 2FA, the failure modes that differ by channel, and the managed SMS OTP loop on Infrai with runnable code.

Choose by who the user is, not by which channel feels safer. If your account identity is an email address, an emailed code is the cheaper and faster thing to ship; if it’s a phone number, SMS is the only code the user can receive. Infrai runs the SMS half as a managed loop — the gateway generates the code, stores it, expires it and counts attempts — so the engineering left to you is the arithmetic and the failure handling.

That arithmetic is where most channel comparisons go wrong. They compare the price of a send, and a send isn’t a login.

Cost per verified login, not cost per send

A verified login is one code that arrived, was read, and was typed back correctly before it expired. Every code that didn’t arrive still cost you money, and every user who asks for a second one costs you again. So the number that belongs in your model is the send price divided by the completion rate, plus the verify.

Infrai charges $0.007475 per SMS and $0.005 for the verify call, both verified 2026-07-26 and both metered per use with no monthly floor. Read them yourself rather than trusting a page — the balance endpoint carries the live rate for every capability plus how many calls your credit covers:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/account/balance" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "balance_usd": 89.55713126,
    "currency": "USD",
    "affordable_uses_hint": {
      "sms.send": { "price_usd": 0.007475, "unit": "per_message", "affordable_uses": 11980, "approximate": true }
    }
  }
}

Two structural facts survive any rate change. Email is roughly 65 times cheaper per message than SMS, because one is an IP packet and the other buys carrier delivery. And rates in this market drift downward — discount campaigns run, vendor mixes shift — so what that call prints today may well be below what’s printed here.

New accounts start with $2 of free credit, which is about 267 SMS messages or an effectively unbounded number of emailed codes. If 8% of your users need a second code before they get in, the blended SMS login lands near 1.4 cents. Twilio Verify, which bundles the same managed loop, publishes $0.05 per successful verification on top of the message itself.

What breaks, and how you find out

The failure modes aren’t symmetric, and that asymmetry should carry more weight than the price gap for anything below a few thousand logins a month.

SMS codeEmail code
Setup before first sendSender registration, days of waitingDNS records, minutes
Typical failureCarrier filters silently, vendor still says sentBounce or spam folder, with an event you can read
Unit costCarrier rate per segmentFractions of a cent
Arrival timeUsually seconds, occasionally minutesSeconds, unless greylisted
Second factor for a password reset?Yes — different device classNo, if the same inbox resets the password
Attacker’s cheapest pathSIM swap, port-out, malicious appCredential-stuffed inbox

The silent-filtering row is the one that costs teams a week of debugging. A US long code without A2P 10DLC registration doesn’t bounce; it gets dropped downstream while the vendor reports a normal send, the message never reaches a handset, and the only signal you get is a support ticket from a user who swears they never received anything — which is why teams shipping SMS for the first time should treat registration as a two-week lead item rather than a checkbox in the launch week.

Email fails loudly. SMS fails quietly.

The SMS half, in two calls

Don’t generate the code yourself. Code generation, TTL and attempt counting all have to fail closed, and the managed route already does that:

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

Note what isn’t in that body: no code field, because you don’t supply one.

The user’s entry goes to the verify route, keyed on the same destination:

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

It answers a single boolean, or fails closed on an expired code, a wrong code or an exhausted attempt budget. Treat all three the same way in your UI — telling a caller which one they hit is free intelligence for someone grinding codes.

Instrument the choice instead of arguing about it

Pick the channel per user, then count what happened. This is Node 22 ESM with no dependencies, and the counters it keeps are the ones that feed the model above.

// otp-channel.mjs — Node 22
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" };
const stats = { sms_sent: 0, sms_verified: 0, sms_rejected: 0 };

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 e = payload.error ?? {};
    const err = new Error(`${path}: ${e.code ?? res.status} ${e.message ?? ""}`);
    err.code = e.code ?? String(res.status);
    throw err;
  }
  return payload.data;
}

export async function sendSmsCode(phone) {
  const data = await post("/v1/sms/otp", { to: phone, template: "login_code_en" });
  stats.sms_sent += 1;
  return data.request_id;
}

export async function checkSmsCode(phone, code) {
  try {
    const data = await post("/v1/sms/verify", { to: phone, code });
    if (data.verified) stats.sms_verified += 1;
    else stats.sms_rejected += 1;
    return Boolean(data.verified);
  } catch (err) {
    if (err.code === "SMS_RATE_LIMIT") throw err;
    stats.sms_rejected += 1;
    return false;
  }
}

export function completionRate() {
  return stats.sms_sent === 0 ? null : stats.sms_verified / stats.sms_sent;
}

const phone = process.argv[2] ?? "+447700900187";
console.log("request:", await sendSmsCode(phone));
console.log("verified:", await checkSmsCode(phone, process.argv[3] ?? "000000"));
console.log("completion rate:", completionRate());

Run it with INFRAI_API_KEY=your_infrai_api_key node otp-channel.mjs +447700900187 573104. A SMS_RATE_LIMIT response is backpressure, not a bug — surface a retry-after rather than looping, because a retry loop against a rate limiter is how a login outage becomes a billing incident.

After a week of real traffic, compare completionRate() per channel. Below roughly 90% on SMS, your sender registration is the problem and no amount of provider shopping fixes it.

Where each channel is the wrong tool

Infrai’s SMS surface is western-region with tencent_sms ready and Twilio pending, and it doesn’t support inbound messages, so two-way flows and reply-based confirmations aren’t something you can build here. If you need a US short code you already own, per-country routing rules you tune yourself, or a carrier relationship on your own paper, stick with Twilio or Plivo and keep it direct — that’s a genuine limitation, not a rounding error.

The email code has a sharper problem that gets waved through: when the inbox also resets the password, an email code isn’t a second factor at all. It’s a slower first one. For a high-value account, a TOTP authenticator beats both channels, costs nothing per login, and works offline.

What makes one credential worth it isn’t the per-message rate. It’s that the login code, the welcome email that follows it, the queue that debounces resend requests, the error tracker that catches a spike in verify failures, and the usage query that tells you which tenant burned the credit all sit on the same account and the same bill.

References

Browse more sms developer guides