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

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.

SymptomWhat it meansCorrect response
400 INVALID_PHONE_NUMBER, retryable: falsemalformed inputreject to the user, never retry
503 VENDOR_DOWN, retryable: trueupstream troubleretry with backoff, then fail the login attempt
503 VENDOR_NOT_CONFIGUREDa configuration gap, not an outagefix the key; a retry loop will not clear it
SMS_RATE_LIMITyou’re going too fastback 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_issuednothing was ever sent to that numbertell the user to request a code — the verify call is billed either way
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.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/send and POST /v1/sms/otp — per_message, $0.008395
  • POST /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.

References

Browse more sms developer guides