A beginner's 2FA login stack: OTP, suppression preflight, polling
Four calls, three failure states and one SLI: the smallest SMS 2FA setup a US or EU SaaS can run on Infrai, with a Node 22 CLI and the alerts worth wiring.
A working SMS 2FA stack is four calls: check the number isn’t suppressed, send the code, verify what the user typed, and read delivery state so you know whether the channel is healthy. Infrai bills only the two that put a message on a carrier; the preflight and the status read cost nothing. Beginners usually ship calls two and three, skip one and four, and then spend a month guessing why some users can’t log in.
Those two skipped calls are the difference between an OTP flow and an operable one. This is the operable version, from an on-call perspective.
The four calls
| Call | Why it’s in the stack | Billing |
|---|---|---|
POST /v1/sms/suppression/check | The number opted out or hard-failed before; a code sent there never arrives | Free, rate-limited |
POST /v1/sms/otp | Managed code: the gateway generates, stores, expires and counts attempts | Per message |
POST /v1/sms/verify | Submit what the user typed; fails closed on expiry or exhausted attempts | Per call |
GET /v1/sms/status/{id} | Delivery state — your only view of the channel’s health | Free, rate-limited |
Everything else on the SMS surface — templates, signatures, batch, cancel — is refinement you can add later. Start with these.
Preflight: the number that can never receive a code
Suppression exists because a user who replied STOP, or a number that hard-failed, must not be messaged again. For alerts that’s a compliance matter. For 2FA it’s a support ticket in the making: the user asks for a code forever, your logs say the send succeeded, and nobody connects the two.
export INFRAI_API_KEY="your_infrai_api_key"
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": "+14155550188"}'
{
"ok": true,
"data": {
"phone": "+14155550188",
"suppressed": false
}
}
One boolean, no cost, and when it comes back true you show a different route into the account instead of burning a message. Listing the whole set needs no parameters at all, which makes it the fastest sanity check that a fresh key works:
curl -sS "https://api.infrai.cc/v1/sms/suppression/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
An empty items array and count: 0 on a new account is the expected answer.
Delivery rate is the SLI, not send count
Your dashboard probably counts codes sent. That number goes up during an outage, because a retry storm is still a send.
The signal worth watching is the ratio of messages reaching delivered to messages queued, sampled over a rolling hour. Below roughly 90% on a US route, suspect A2P 10DLC registration rather than the API — filtered traffic is reported as sent, so the drop shows up here and nowhere else. A sudden fall to zero on a single country is usually a carrier or vendor problem, and the fix is a support ticket, not a code change.
Poll the id you got from the send:
curl -sS "https://api.infrai.cc/v1/sms/status/sms_2Wp6xLnB8vRq" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"id": "sms_2Wp6xLnB8vRq",
"status": "delivered",
"found": true,
"vendor": "tencent_sms",
"to": "+14155550188",
"delivered_at": "2026-07-26T11:31:44Z",
"failed_reason": null
}
}
Worth flagging a rough edge here: the archive record keys the lifecycle as status, while the richer delivery-tracking fields are optional and often absent. Read status first and fall back to state — the CLI below does exactly that.
The whole thing as a CLI
Node 22, standard library only, four subcommands matching the four calls. Running it is how a new engineer learns the flow without a console.
#!/usr/bin/env node
// otp-cli.mjs — Node 22, no dependencies
import { parseArgs } from "node:util";
const HOST = "https://api.infrai.cc";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) { console.error("INFRAI_API_KEY is not set"); process.exit(2); }
const TERMINAL = new Set(["delivered", "failed", "expired", "cancelled", "auto_suppressed"]);
async function request(method, path, body) {
const res = await fetch(HOST + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json();
if (json.ok === false) {
const { code = `HTTP_${res.status}`, message = "" } = json.error ?? {};
return { failed: true, code, message };
}
return { failed: false, ...json.data };
}
const { values } = parseArgs({
options: {
cmd: { type: "string", default: "preflight" },
phone: { type: "string", default: "+14155550188" },
code: { type: "string", default: "" },
id: { type: "string", default: "" },
},
});
switch (values.cmd) {
case "preflight": {
const out = await request("POST", "/v1/sms/suppression/check", { phone: values.phone });
console.log(out.suppressed ? "SUPPRESSED — offer another channel" : "clear to send");
break;
}
case "send": {
const out = await request("POST", "/v1/sms/otp", { to: values.phone, template: "login_code_en" });
console.log(out.failed ? `send failed: ${out.code}` : `requested: ${out.request_id}`);
break;
}
case "verify": {
const out = await request("POST", "/v1/sms/verify", { to: values.phone, code: values.code });
console.log(out.verified === true ? "verified" : `rejected (${out.code ?? "bad code"})`);
break;
}
case "status": {
const out = await request("GET", `/v1/sms/status/${values.id}`);
const state = out.status ?? out.state ?? "unknown";
console.log(`${values.id} -> ${state}${TERMINAL.has(state) ? " (terminal)" : ""}`);
break;
}
default:
console.error(`unknown --cmd ${values.cmd}`);
process.exit(2);
}
Invoke it as node otp-cli.mjs --cmd send --phone +14155550188, then --cmd verify --code 573104. Errors come back as data rather than exceptions, which is deliberate: an on-call engineer running this at 3am wants a code string, not a stack trace.
The third failure mode is your balance
Two of the four calls cost money, and an account at zero fails every login at once. That’s the incident nobody rehearses.
The send is $0.007475 per message and the verify is $0.005 per call, both verified 2026-07-26, with the suppression check and status read free but rate-limited. New accounts get $2 of credit, which covers a few hundred logins while you’re building. Wire the balance read into the same health check that pings your database:
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"balance_usd": 89.55713126,
"runway_days": 344.81,
"affordable_uses_hint": {
"sms.send": { "price_usd": 0.007475, "unit": "per_message", "affordable_uses": 11980, "approximate": true }
}
}
}
runway_days is the field to alert on — page someone below 7 days, not below zero. Prices in this market keep drifting down, so what that call prints may be under the figures quoted here.
Which errors deserve a page
SMS_RATE_LIMIT is backpressure: surface a retry-after to the user, log it, and only alert if it’s sustained. A vendor-configuration error is different — it means no login can complete, and it should wake somebody. A single failed_reason on one message is noise; the same reason across a country in ten minutes is an incident.
Don’t retry a verify failure automatically. Ever.
When something else is the better buy
If you want SMS with automatic voice or WhatsApp fallback inside one verification API, Vonage Verify and Twilio Verify both do that and this doesn’t — a genuine limitation of Infrai’s SMS surface, along with no inbound route unless an inbound-capable vendor is configured, and western-region coverage with tencent_sms ready and Twilio pending. If your product is a single login screen and nothing else, a specialist verification API is a defensible choice.
The reason a beginner stack ends up here anyway is that the next four things you’ll need — a queue for the retry, a cron sweep for stale pending logins, error tracking for the verify failures, and a usage query that says what 2FA cost this month — are already on the same key. No second vendor, no second invoice, no second on-call runbook.