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/otpLane B — POST /v1/sms/send
Who generates the codethe gatewayyou
Where the code livesgateway storage, never yoursyour database (store a hash)
Expiry and attempt limitsmanagedyour code
VerificationPOST /v1/sms/verifyyour own comparison
Returns a message_idnoyes
Delivery pollingnot possibleGET /v1/sms/status/{id}
Billingper message, plus per verify callper message
Default forevery loginthe “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

Failures on this surface are not shaped the way a REST client expects. Send to a number that isn’t in E.164 and you don’t get a 400:

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": "VENDOR_DOWN",
    "http_status": 503,
    "message": "recipient not in E.164 format: '07700900123'",
    "retryable": true,
    "code_detail": "live_vendor"
  }
}

HTTP 503, retryable: true, and the real reason available only as prose in message. A generic “retry on 5xx with backoff” client will loop on that forever, because the payload will never become valid. Treat retryable as a hint, not an instruction — and classify on the message before you decide.

SymptomWhat it meansCorrect response
503 VENDOR_DOWN, message mentions E.164malformed inputreject to the user, never retry
503 VENDOR_DOWN, generic messageactual upstream troubleretry once, then fail the login attempt
SMS_RATE_LIMITyou’re going too fastback off; there are no X-RateLimit-* headers to read
verified: false, reason no_code_issuednothing was ever sent to that numbertell the user to request a code — and note you were billed anyway
Lane B: state stuck, failed_reason setcarrier rejected itsuppress 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\.164|not in E\.164/.test(e.message) });
  }
  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}"

Verified 2026-07-26 on Infrai: a message is billed per message at $0.007475, a managed verify per call at $0.005, and every read — status, suppression check, usage — is free and rate-limited. A clean managed login is about $0.0125; one that escalates to lane B and gets polled a dozen times is about $0.02, because the polling itself is free and only the extra message costs anything. New accounts get $2 of free credit, around 267 messages. Read the live figures from GET /v1/discovery and your real spend from GET /v1/account/usage; rates on this surface have trended down and campaigns run, so what you find may well be lower than what’s printed here.

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 queue that retries a failed send, the cron that expires stale challenges, the error capture around the Express handler and the email you fall back to when the phone is dead are all on the account you already have, billed on one invoice and attributable per tenant with a single usage query. For most teams shipping a login form in 2026, that beats a receipt stream they’d read twice a year.

References

Browse more sms developer guides