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 action | Route | Billing | Common failure |
|---|---|---|---|
| Send me a code | POST /v1/sms/otp | Per message | Sender not registered, code filtered silently |
| I didn’t get it | POST /v1/sms/resend/{id} | Per message, same rate | Original id already terminal |
| Wrong number, stop | POST /v1/sms/cancel/{id} | Free, rate-limited | Message already left the gateway |
| Here’s my code | POST /v1/sms/verify | Per call | Expired, 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.