SMS 2FA in Express: switch lanes when the code never arrives
You can't poll delivery for a managed OTP. Here's an Express flow that runs the safe path by default and moves a stuck user onto a pollable one, plus the failure taxonomy.
Start with the constraint, because it decides the architecture. Infrai’s managed OTP call, POST /v1/sms/otp, answers with {request_id, sent}. The delivery-status route, GET /v1/sms/status/{id}, is keyed on the message_id that only POST /v1/sms/send hands back. There is no join between the two — so for a managed one-time passcode, “poll the delivery status” isn’t a thing you can do.
That’s not a dead end, it’s a fork. You can run the managed lane for everyone and move the small number of users who report a missing code onto a self-issued lane where a message_id exists and polling works.
Two lanes
Lane A — POST /v1/sms/otp | Lane B — POST /v1/sms/send | |
|---|---|---|
| Who generates the code | the gateway | you |
| Where the code lives | gateway storage, never yours | your database (store a hash) |
| Expiry and attempt limits | managed | your code |
| Verification | POST /v1/sms/verify | your own comparison |
Returns a message_id | no | yes |
| Delivery polling | not possible | GET /v1/sms/status/{id} |
| Billing | per message, plus per verify call | per message |
| Default for | every login | the “I didn’t get it” path |
Lane A is the right default and it isn’t close. A code you never store is a code that can’t leak out of your database, and letting the gateway own expiry and attempt counting removes the two bugs most home-grown OTP implementations ship with. Lane B exists because diagnosis needs a handle.
Handling a failed send
The good news is that failures on this surface are shaped the way a REST client already expects, so your existing retry policy transfers unchanged. Send to a number that isn’t in E.164 and you get a 400 that says so:
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": "07700900123", "template": "Your sign-in code is {code}"}'
{
"ok": false,
"error": {
"code": "INVALID_PHONE_NUMBER",
"http_status": 400,
"message": "recipient not in E.164 format: '07700900123'",
"docs_url": "https://docs.infrai.cc/errors/INVALID_PHONE_NUMBER",
"retryable": false,
"hint": "Phone number must use E.164 format."
}
}
retryable: false on a 4xx, a stable code to branch on, and a docs_url you can put straight into a log line. So the boring rule holds here: retry the 5xx class with backoff, never retry a 4xx. Branch on error.code, not on the message text — the code is the contract and the prose is not.
| Symptom | What it means | Correct response |
|---|---|---|
400 INVALID_PHONE_NUMBER, retryable: false | malformed input | reject to the user, never retry |
503 VENDOR_DOWN, retryable: true | upstream trouble | retry with backoff, then fail the login attempt |
503 VENDOR_NOT_CONFIGURED | a configuration gap, not an outage | fix the key; a retry loop will not clear it |
SMS_RATE_LIMIT | you’re going too fast | back off — every response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset, so budget against those rather than guessing |
verified: false, reason no_code_issued | nothing was ever sent to that number | tell the user to request a code — the verify call is billed either way |
Lane B: state stuck, failed_reason set | carrier rejected it | suppress and offer another factor |
Check the suppression list before spending a message on a number that can’t receive one. This route is a POST but it’s a read — nothing changes:
curl -sS -X POST "https://api.infrai.cc/v1/sms/suppression/check" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"phone": "+15551234567"}'
{
"ok": true,
"data": { "phone": "+15551234567", "suppressed": false }
}
Lane B, where polling works
curl -sS -X POST "https://api.infrai.cc/v1/sms/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to": "+15551234567", "body": "Your sign-in code is 481920. It expires in 5 minutes."}'
curl -sS "https://api.infrai.cc/v1/sms/status/sms_notarealid" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": false,
"error": {
"code": "SMS_MESSAGE_NOT_FOUND",
"http_status": 404,
"message": "no sms message with id 'sms_notarealid' in this account's archive",
"retryable": false
}
}
A live id answers with state, vendor, attempt, last_event, delivered_at and failed_reason instead — and failed_reason is the sentence you actually want in your support tooling when someone says the text never came.
The Express app
// server.mjs — SMS 2FA with a diagnostic lane. Node 22, Express 5.
// Run: INFRAI_API_KEY=your_infrai_api_key node server.mjs
import express from "express";
import { createHash, randomInt, 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 app = express();
app.use(express.json());
const sessions = new Map(); // phone -> { lane, hash, expiresAt, messageId, tries }
const throttle = new Map(); // phone -> timestamps
async function api(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (json.ok === false) {
const e = json.error;
throw Object.assign(new Error(e.message), { code: e.code, terminal: e.retryable === false });
}
return json.data;
}
function allow(phone, limit, windowMs) {
const now = Date.now();
const hits = (throttle.get(phone) ?? []).filter((t) => now - t < windowMs);
if (hits.length >= limit) return false;
hits.push(now);
throttle.set(phone, hits);
return true;
}
app.post("/auth/start", async (req, res) => {
const { phone } = req.body ?? {};
if (!/^\+[1-9]\d{7,14}$/.test(String(phone ?? ""))) {
return res.status(400).json({ error: "phone must be E.164, e.g. +15551234567" });
}
if (!allow(phone, 4, 3_600_000)) return res.status(429).json({ error: "too_many_codes" });
try {
const check = await api("POST", "/v1/sms/suppression/check", { phone });
if (check.suppressed) return res.status(409).json({ error: "number_opted_out" });
await api("POST", "/v1/sms/otp", { to: phone, template: "Your sign-in code is {code}" });
sessions.set(phone, { lane: "managed", tries: 0 });
return res.json({ lane: "managed", pollable: false });
} catch (err) {
const status = err.terminal ? 400 : 502;
return res.status(status).json({ error: err.code, detail: err.message });
}
});
// The "I didn't get it" button. Re-sends on the pollable lane so support can see why.
app.post("/auth/escalate", async (req, res) => {
const { phone } = req.body ?? {};
if (!allow(phone, 2, 3_600_000)) return res.status(429).json({ error: "escalation_limit" });
const code = String(randomInt(100_000, 999_999));
try {
const sent = await api("POST", "/v1/sms/send", {
to: phone,
body: `Your sign-in code is ${code}. It expires in 5 minutes.`,
});
sessions.set(phone, {
lane: "self",
hash: createHash("sha256").update(code).digest("hex"),
expiresAt: Date.now() + 300_000,
messageId: sent.message_id,
tries: 0,
});
return res.json({ lane: "self", pollable: true, message_id: sent.message_id });
} catch (err) {
return res.status(err.terminal ? 400 : 502).json({ error: err.code, detail: err.message });
}
});
app.get("/auth/diagnose/:phone", async (req, res) => {
const s = sessions.get(req.params.phone);
if (!s?.messageId) return res.status(404).json({ error: "nothing_pollable_for_this_user" });
try {
const status = await api("GET", `/v1/sms/status/${encodeURIComponent(s.messageId)}`);
return res.json({ state: status.state, failed_reason: status.failed_reason ?? null });
} catch (err) {
return res.status(502).json({ error: err.code });
}
});
app.post("/auth/verify", async (req, res) => {
const { phone, code } = req.body ?? {};
const s = sessions.get(phone);
if (!s) return res.status(400).json({ error: "no_challenge" });
if (!allow(`verify:${phone}`, 6, 900_000)) return res.status(429).json({ error: "too_many_attempts" });
s.tries += 1;
if (s.lane === "self") {
if (Date.now() > s.expiresAt) return res.status(401).json({ verified: false, reason: "expired" });
const got = createHash("sha256").update(String(code ?? "")).digest();
const want = Buffer.from(s.hash, "hex");
const ok = got.length === want.length && timingSafeEqual(got, want);
if (ok) sessions.delete(phone);
return res.status(ok ? 200 : 401).json({ verified: ok });
}
try {
const out = await api("POST", "/v1/sms/verify", { to: phone, code: String(code ?? "") });
if (out.verified) sessions.delete(phone);
return res.status(out.verified ? 200 : 401).json({ verified: out.verified === true });
} catch (err) {
return res.status(502).json({ error: err.code });
}
});
app.listen(3000, () => console.log("listening on :3000"));
Four routes, and the interesting one is /auth/escalate. It costs a second message, which is why it’s throttled harder than the first — two per hour, against four for the initial send.
Verification failures cost money
This is the part worth putting in front of a security review.
POST /v1/sms/verify is billed per call whether or not the code matches. Submit a wrong code against a number that never had a challenge and the response is verified: false with reason no_code_issued — and the call is still charged to the account owner. An unthrottled verify endpoint is therefore not just a brute-force surface, it’s a way for a stranger to spend your balance. The allow() guard in front of the route is doing security and budget work, and the per-phone counter matters more than the per-IP one, since a botnet rotates addresses and phone numbers cost real money to acquire.
Store a hash on lane B, never the code itself. NIST SP 800-63B treats SMS as a restricted authenticator for good reasons, so if your threat model includes SIM swap, this whole design is a stepping stone to WebAuthn rather than a destination.
What the flow costs
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | head -c 300
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
What that first call printed on 2026-07-27:
POST /v1/sms/sendandPOST /v1/sms/otp—per_message, $0.008395POST /v1/sms/verify—per_call, $0.005- every read in this article — status, suppression check, usage — free and rate-limited
Which makes the cost model easy to reason about without doing any arithmetic: a clean managed login is one message plus one verify, and an escalated one adds exactly one more message. The polling is free, so diagnosing a stuck user costs nothing beyond the second text. That structure is what to design against; the two figures above are today’s reading and rates on this surface have trended down, so pull GET /v1/discovery for the current ones and GET /v1/account/usage for what you actually spent.
Where to stick with a specialist
If 2FA is your product’s core security boundary, Twilio Verify and Sinch Verification both offer things this doesn’t: voice and WhatsApp fallback channels, per-country routing controls, SMS-pumping fraud detection, and push-based delivery receipts that make lane B unnecessary. Infrai doesn’t support delivery webhooks here, GET /v1/sms/events/{id} needs a hydrated vendor key before it returns a timeline, and there’s no provider-side anti-fraud scoring — those are real limitations and you should weigh them.
What you get instead is the second question answered on the same credential. The sweep that expires stale challenges is a POST /v1/cron/create job. The email you fall back to when the handset is dead is POST /v1/email/send. The 502 branch in every handler above belongs in POST /v1/errors/capture, with the phone hashed and the lane tagged. None of those need another account, another key rotation or another invoice — and for most teams shipping a login form, four fewer vendor relationships beats a delivery-receipt stream they’d read twice a year.