2FA SMS API with resend and cancel: a Node 22 login controller

The four buttons a 2FA screen needs, why resend and cancel key off a message id, cooldown logic that protects your balance, and runnable Node code on Infrai.

The “I didn’t get the code” button is the part of a 2FA screen that decides your support load, and most tutorials stop before it. What you want from a provider is resend and cancel as ordinary routes with defined billing, plus a message id you can hold onto. Infrai exposes both — sms.resend costs the same as a fresh message, sms.cancel is free — and the id that makes them work comes back from the send.

There’s a wrinkle in that sentence worth getting straight before you write the controller, because it changes which route your login screen calls.

The four buttons, and what each one costs

User actionRouteBillingCommon failure
Send me a codePOST /v1/sms/otpPer messageSender not registered, code filtered silently
I didn’t get itPOST /v1/sms/resend/{id}Per message, same rateOriginal id already terminal
Wrong number, stopPOST /v1/sms/cancel/{id}Free, rate-limitedMessage already left the gateway
Here’s my codePOST /v1/sms/verifyPer callExpired, wrong, or attempts exhausted

Cancel is only meaningful in the window between queueing and dispatch, which is often under a second on a domestic route. Treat it as a best-effort saving rather than a guarantee — if the carrier already has the message, you’re cancelling nothing.

Managed codes or a message you own

The managed loop generates, stores and expires the code for you. It answers with a request_id:

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": "+14155550194", "template": "login_code_en"}'
{
  "ok": true,
  "data": {
    "request_id": "otpreq_7be0c2f931",
    "sent": true
  }
}

Notice what that response does not contain: a message id. The resend and cancel routes take the message_id that a plain send returns, so with the managed loop a resend is simply a second call to the same OTP route — the gateway re-issues the code — and there is nothing to cancel.

That’s the trade-off in one line. Managed codes give you correct expiry and attempt counting for free; owning the message gives you the id that makes resend and cancel addressable.

Most login screens should take the managed route and treat “resend” as “ask again”. Build the id-addressable version when your product needs to show delivery state on screen, or when a human agent needs to pull a code back.

curl -sS -X POST "https://api.infrai.cc/v1/sms/resend/sms_5Rq9tKmZ3wLd" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS -X POST "https://api.infrai.cc/v1/sms/cancel/sms_5Rq9tKmZ3wLd" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

An id from another account, or one that aged out of the archive, answers SMS_MESSAGE_NOT_FOUND rather than pretending to succeed.

Cooldowns are yours to enforce

Nothing in the API stops a bored user pressing resend eleven times, and eleven presses is eleven messages you paid for. The gateway applies its own rate limit and returns SMS_RATE_LIMIT when you cross it, but that limit exists to protect the platform, not your balance.

Thirty seconds between resends, three resends per destination per fifteen minutes, and a hard daily ceiling per account is a sane starting shape. Enforce it server-side — a disabled button is decoration.

The controller, in Node 22

No dependencies, no SDK. This is the whole server-side half of a 2FA screen, cooldown included.

// login-2fa.mjs — Node 22 ESM
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const RESEND_COOLDOWN_MS = 30_000;
const MAX_SENDS_PER_WINDOW = 3;
const WINDOW_MS = 15 * 60_000;

// destination -> { last: epochMs, sends: number[], windowStart: epochMs }
const attempts = new Map();

async function api(path, payload) {
  const response = await fetch(API + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload ?? {}),
  });
  const parsed = await response.json();
  if (!response.ok || parsed.ok === false) {
    const detail = parsed.error ?? { code: `HTTP_${response.status}`, message: "" };
    throw Object.assign(new Error(`${detail.code} ${detail.message}`), { code: detail.code });
  }
  return parsed.data;
}

function gate(phone) {
  const now = Date.now();
  const record = attempts.get(phone) ?? { last: 0, sends: 0, windowStart: now };
  if (now - record.windowStart > WINDOW_MS) { record.sends = 0; record.windowStart = now; }
  const waitMs = RESEND_COOLDOWN_MS - (now - record.last);
  if (waitMs > 0) return { allowed: false, retryAfterSec: Math.ceil(waitMs / 1000) };
  if (record.sends >= MAX_SENDS_PER_WINDOW) return { allowed: false, retryAfterSec: 900 };
  record.last = now;
  record.sends += 1;
  attempts.set(phone, record);
  return { allowed: true, retryAfterSec: 0 };
}

export async function requestCode(phone) {
  const decision = gate(phone);
  if (!decision.allowed) return { status: "cooldown", retryAfterSec: decision.retryAfterSec };
  const data = await api("/v1/sms/otp", { to: phone, template: "login_code_en" });
  return { status: "sent", requestId: data.request_id };
}

export async function submitCode(phone, code) {
  try {
    const data = await api("/v1/sms/verify", { to: phone, code });
    return data.verified ? { status: "ok" } : { status: "rejected" };
  } catch (err) {
    if (err.code === "SMS_RATE_LIMIT") return { status: "throttled" };
    return { status: "rejected" };
  }
}

const phone = process.argv[2] ?? "+14155550194";
console.log(await requestCode(phone));
console.log(await requestCode(phone)); // second press inside the cooldown
console.log(await submitCode(phone, process.argv[3] ?? "000000"));

Run it with INFRAI_API_KEY=your_infrai_api_key node login-2fa.mjs +14155550194. The second requestCode returns { status: "cooldown", retryAfterSec: 30 } without touching the API, which is the entire point — the cheapest message is the one you don’t send. In a real deployment that Map belongs in Redis or your database, because a process restart shouldn’t hand an attacker a fresh budget.

Return the same “we’ve sent a code” copy whether or not the number is known to you. Confirming which phone numbers have accounts is a free gift to someone enumerating them.

What the lifecycle costs

Every message-producing step bills the same way: $0.007475 per message for the initial send and for each resend, verified 2026-07-26 and marked approximate. The verify call is $0.005. Cancel, status and event reads are free but rate-limited, and new accounts get $2 of credit — about 267 messages. A user who needs one resend therefore costs roughly two cents to log in.

Pull today’s figures rather than trusting the paragraph above:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | node --input-type=module -e '
import { readFileSync } from "node:fs";
const doc = JSON.parse(readFileSync(0, "utf8"));
const caps = (doc.data ?? doc).capabilities ?? [];
for (const cap of caps.filter((c) => ["sms.otp", "sms.resend", "sms.verify", "sms.cancel"].includes(c.id))) {
  console.log(cap.id, cap.billing.price_usd ?? "free", cap.billing.unit);
}'

Rates here drift down rather than up, and discount campaigns run, so the number that prints may be lower than the one quoted.

Where a different provider fits better

Twilio Verify wraps the same lifecycle with its own resend semantics and a per-verification price, and if you want channel fallback to voice or WhatsApp inside one API, that’s a real capability Infrai’s SMS surface doesn’t support. Vonage is worth a look if you need per-country sender rules you tune yourself. Infrai’s SMS is western-region with tencent_sms ready and Twilio pending, and there’s no inbound route without a configured inbound-capable vendor, so reply-to-confirm flows can’t be built here.

What tips it for a small team is that the login codes, the session store, the queue that retries a failed send, the error tracker that catches a resend storm and the usage query that tells you what 2FA cost last month are one account, one key and one bill.

References

Browse more sms developer guides