SMS OTP vs email OTP for SaaS login: deliverability, limits, fallback
Which channel to make primary for US and EU logins, the three rate-limit counters that stop abuse, and a working fallback ladder on Infrai's managed OTP routes.
Make email the primary channel and SMS the step-up. For a US or EU SaaS login, email OTP reaches every user who already has an account, costs almost nothing to retry, and doesn’t depend on carrier registration. Infrai exposes both as managed loops — POST /v1/sms/otp and POST /v1/auth/email/send_code generate and store the code themselves — so the interesting engineering isn’t the send, it’s the rate limiting and the fallback.
SMS earns its place in two situations: your users are on mobile-first accounts where email is the thing they can’t reach, or you need a second factor that isn’t the same inbox that can reset the password. Everything else is habit.
Deliverability decides this, not security theory
The failure modes are not symmetric, and that asymmetry is what should drive the choice.
An SMS fails because of registration. US carriers require A2P 10DLC brand and campaign registration for long-code traffic, and unregistered senders get filtered rather than bounced — you see delivered from the vendor and the user sees nothing. In much of the EU, alphanumeric sender IDs work but the rules differ per country, and France and Italy have their own registration regimes. Sender registration takes days, not minutes, and there’s no way to hurry it.
An email fails because of authentication and reputation. SPF, DKIM and DMARC on the sending domain are table stakes, and a cold domain sending 5,000 OTPs on day one gets throttled. The difference is that you control all of it, and you can watch it: bounce and complaint events land on the message record.
One channel’s setup work is a queue you wait in. The other is a checklist you finish.
Use the managed loop, not a code you generated
The temptation is to generate a six-digit code, store it in Redis with a TTL, and push it through a plain send. Resist that. Code generation, TTL and attempt counting all have to fail closed, and re-implementing them per channel is how a login gets a replay bug.
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": "+447700900123",
"template": "login_code_en"
}'
{
"ok": true,
"data": {
"request_id": "otpreq_5f2c9ad41b",
"sent": true
}
}
The body is to and template — no code field, because you don’t supply one. Submitting the user’s entry goes to a second route:
curl -sS -X POST "https://api.infrai.cc/v1/sms/verify" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"to": "+447700900123",
"code": "418293"
}'
It returns {"verified": true} or fails closed on an expired code or an exhausted attempt budget. The email side has the same two-call shape, and its verify doubles as the login:
curl -sS -X POST "https://api.infrai.cc/v1/auth/email/send_code" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com"}'
curl -sS -X POST "https://api.infrai.cc/v1/auth/email/verify" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "code": "418293"}'
That second call is the login:
{
"ok": true,
"data": {
"verified": true,
"user_id": "usr_7Jq3xnR2",
"created": false,
"session_id": "sess_2Kd9pWm4",
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.example",
"refresh_token": "rt_8Hs1vQz6",
"expires_at": 1784943003
}
}
created: true means the address had no account and one was just made — signup and login are the same request, which removes a whole screen from the flow.
Three send counters, plus one on verify
Most OTP abuse is not code guessing. It’s someone burning your balance by requesting codes to numbers they don’t own, or grinding a single number to lock a real user out. You need three separate limits and they fail differently:
| Counter | Sensible ceiling | What it stops | Response when tripped |
|---|---|---|---|
| Per destination (phone or email) | 3 sends per 15 min | Grinding one victim | 429, generic message |
| Per IP or device | 10 sends per hour | Enumeration by a script | 429, then captcha |
| Per account, per day | A hard cap you pick | Runaway spend from a bug | Alert and stop sending |
| Verify attempts per code | 5 | Brute-forcing six digits | Invalidate the code |
The gateway enforces its own limit on top of yours and returns SMS_RATE_LIMIT when you cross it, which you should treat as backpressure rather than a bug — surface a retry-after to the user instead of retrying in a loop. Don’t leak whether the destination exists; return the same “we sent a code” copy either way.
The fallback ladder
Here’s the pattern in Node 22 ESM. The rule is simple: try the requested channel once, and if the send itself errors, drop to email rather than making the user try again into the same failure.
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is missing");
const HEADERS = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function call(path, body) {
const res = await fetch(`https://api.infrai.cc${path}`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(body),
});
const payload = await res.json();
if (!res.ok || payload.ok === false) {
const code = payload.error?.code ?? String(res.status);
const err = new Error(`${path}: ${code}`);
err.code = code;
throw err;
}
return payload.data;
}
const RETRYABLE_TO_EMAIL = new Set([
"SMS_SEND_FAILED",
"SMS_VENDOR_DOWN",
"SMS_SENDER_NOT_REGISTERED",
"VENDOR_NOT_CONFIGURED",
]);
export async function startLogin({ email, phone, prefer }) {
if (prefer === "sms" && phone) {
try {
await call("/v1/sms/otp", { to: phone, template: "login_code_en" });
return { channel: "sms", destination: phone };
} catch (err) {
if (err.code === "SMS_RATE_LIMIT") throw err;
if (!RETRYABLE_TO_EMAIL.has(err.code)) throw err;
}
}
await call("/v1/auth/email/send_code", { email });
return { channel: "email", destination: email };
}
Store the returned channel on the pending-login record. Verifying against the wrong route is the single most common bug in a two-channel OTP flow, and it looks exactly like “the code doesn’t work”.
Rate-limit rejections are deliberately not retried here. A 429 that you paper over becomes a 429 you never see.
What a completed login costs
Read the current figures rather than trusting a page, including this one:
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The response carries an affordable_uses_hint block keyed by capability, so you get the live per-call price and how many of them your balance covers. Verified 2026-07-25, an Infrai SMS OTP send is $0.007475 per message and the verify call is $0.005, so a completed SMS login is roughly $0.0125. Twilio Verify publishes $0.05 per successful verification plus $0.0083 for the US SMS itself, near $0.058 all in. The email leg is free per call at the auth routes.
New accounts get $2 free credit, which covers about 160 completed SMS logins or an effectively unbounded number of email ones. Rates in this market drift downward and discount campaigns run, so the reading you take may be lower than the one printed here.
Where each channel falls short
Infrai’s SMS surface is western-region with tencent_sms ready and Twilio pending, so if you need a US short code you already own or per-country routing rules you tune yourself, stick with Twilio or Vonage and keep the carrier relationship direct. Inbound SMS is a separate matter — replies aren’t handled here.
Email OTP has a sharper limitation that gets ignored: if the inbox is also the password-reset destination, an email code is not a second factor. It’s a slower first one. For genuine 2FA on a high-value account, a TOTP authenticator beats both channels and costs nothing per login.
The reason to run this on one credential isn’t the per-message rate. It’s that the login codes, the welcome email that follows, the queue that debounces resends, the error tracking that catches a spike in verify failures, and the usage query that tells you which tenant burned the balance are all the same account and the same bill.