OTP login with no delivery webhooks: what polling really tells you
Poll-only SMS delivery data changes your login design. Which signal to trust, how often to read it, and how to stop resend abuse — with a Node 22 controller.
A provider without delivery webhooks doesn’t break OTP login — it just moves the delivery signal out of your request path. Poll for operations, never for the user’s next screen: show the code entry field the moment the send is accepted, and let the verify rate, not a carrier receipt, tell you whether the channel is healthy. Infrai’s SMS surface is poll-only by design, so this is the shape you get.
That constraint is smaller than it sounds, and the reason is uncomfortable.
Carrier delivery receipts lie in both directions. A delivered receipt means the handset acknowledged a message, not that a human read a code, and plenty of successful logins happen on numbers whose receipt never came back at all. Your verify success rate is a better health metric than any DLR stream — you just have to build for it deliberately.
What you can actually know, and when
| Signal | How you read it | When it becomes true | What it’s good for |
|---|---|---|---|
| Send accepted | The response to the OTP call | Immediately | Rendering the code field, starting the resend timer |
| Per-message delivery state | GET /v1/sms/status/{id} | Seconds to minutes later | Ops dashboards, per-carrier failure hunting |
| Opt-out / hard failure | GET /v1/sms/suppression/list | Whenever the carrier tells us | Excluding numbers that can never receive a code |
| The only signal that matters | Your own verify success counter | When the user types the code | Alerting, channel comparison, fallback decisions |
Row four is the one to instrument first. Rows one to three are supporting evidence.
Managed OTP trades telemetry for safety
There’s a fork here that most OTP tutorials skip. POST /v1/sms/otp is the managed path: the gateway generates the code, stores it, expires it and counts attempts, and hands you back a request_id plus sent. Your database never holds a recoverable secret, which is the right default for a login form.
POST /v1/sms/send is the unmanaged path: you write the body, you own the code, and you get a message_id — the handle that GET /v1/sms/status/{id} is keyed on.
So the real question behind “no webhooks” is which of those two you picked. Managed OTP buys you correct expiry and attempt limits; the trade-off is that per-message delivery telemetry belongs to the send route.
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": "+15551234567", "template": "login"}'
{
"ok": true,
"data": { "request_id": "smsotp_9TkQm2xBvR7", "sent": true },
"metadata": {
"request_id": "req_25e298add8c84c5c8ad5cd0c",
"vendor": "tencent_sms",
"cost_usd": 0.008395
}
}
The poll loop that stays out of the login path
Run it from a worker, keyed on the message_id from a POST /v1/sms/send, and treat it as telemetry rather than a gate. Three reads spread over about 90 seconds catch nearly everything worth catching; hammering it every second buys nothing and will meet SMS_RATE_LIMIT.
curl -sS "https://api.infrai.cc/v1/sms/status/sms_7Qd1mKpR2xVbN8sTfLgA" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": false,
"error": {
"code": "SMS_MESSAGE_NOT_FOUND",
"http_status": 404,
"message": "no sms message with id 'sms_7Qd1mKpR2xVbN8sTfLgA' in this account's archive",
"retryable": false
}
}
That’s what an unknown id looks like, and it’s worth showing because it’s the response you’ll hit first while wiring the worker up. A real id returns state, attempt, last_event, delivered_at and failed_reason, and GET /v1/sms/events/{id} gives the per-attempt timeline behind it when you’re chasing one carrier.
The worker itself doesn’t need a second product either. POST /v1/queue/publish holds the message ids you still owe a poll, POST /v1/errors/capture catches the ones that blow up mid-loop, and POST /v1/metrics/report carries your verify success counter — all on the same key that sent the code, with no second account and no second bill to reconcile at the end of the month.
Resend, cooldowns, and the counter nobody sets
Without webhooks, users click resend more. That’s the actual UX consequence, and it’s also your abuse surface.
Three limits, all enforced by you rather than the API: a cooldown between resends for one phone number (30 seconds is a reasonable floor), a hard cap per number per hour, and a cap per IP or per session. The catch is that verification attempts are billed whether or not they succeed — a verified: false response with reason: "no_code_issued" still costs a call — so an unthrottled verify endpoint is a way for a stranger to spend your credit. Put the throttle in front of the route, not behind it.
Check who’s already unreachable before any of this:
curl -sS "https://api.infrai.cc/v1/sms/suppression/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The login controller, in Node 22
// otp-controller.mjs — issue, resend with cooldown, verify. No webhooks needed.
// Run: INFRAI_API_KEY=your_infrai_api_key node otp-controller.mjs
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 COOLDOWN_MS = 30_000;
const MAX_SENDS_PER_HOUR = 5;
const attempts = new Map(); // phone -> { last: number, window: number[] }
async function call(method, path, payload) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: payload === undefined ? undefined : JSON.stringify(payload),
});
const json = await res.json();
if (json.ok === false) {
const e = json.error;
throw Object.assign(new Error(e.message), { code: e.code, retryable: e.retryable });
}
return json.data;
}
function throttle(phone) {
const now = Date.now();
const rec = attempts.get(phone) ?? { last: 0, window: [] };
rec.window = rec.window.filter((t) => now - t < 3_600_000);
if (now - rec.last < COOLDOWN_MS) {
throw new Error(`cooldown: retry in ${Math.ceil((COOLDOWN_MS - (now - rec.last)) / 1000)}s`);
}
if (rec.window.length >= MAX_SENDS_PER_HOUR) throw new Error("hourly send cap reached");
rec.last = now;
rec.window.push(now);
attempts.set(phone, rec);
}
export async function issueCode(phone) {
throttle(phone);
const data = await call("POST", "/v1/sms/otp", { to: phone, template: "login" });
return { request_id: data.request_id, resend_after_ms: COOLDOWN_MS };
}
export async function checkCode(phone, code) {
const data = await call("POST", "/v1/sms/verify", { to: phone, code });
return data.verified === true;
}
const phone = "+15551234567";
const issued = await issueCode(phone);
console.log("issued", issued);
try {
await issueCode(phone);
} catch (err) {
console.log("second request correctly refused:", err.message);
}
console.log("verify result:", await checkCode(phone, "000000"));
The in-memory Map is fine for one process and wrong for three — move it to Redis or a sms_challenges row before you scale out, because a cooldown that only holds on one instance isn’t a cooldown.
What the polling actually costs
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" | head -c 400
curl -sS "https://api.infrai.cc/v1/account/balance" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Polling is free. Every read on this surface — status, events, suppression — is a free call on Infrai, so a three-read worker per message costs nothing and you can tune the cadence for signal rather than for budget. Only two things in the login flow are billable, and this is today’s reading:
| Call | Rate |
|---|---|
POST /v1/sms/otp | $0.008395 per message |
POST /v1/sms/verify | $0.005 per call |
any GET on the SMS surface | free |
New accounts start with $2 of free credit, which is enough to run the whole issue-poll-verify loop against real numbers before you commit to it. Per-message rates move with carrier deals, so read GET /v1/discovery for today’s figure instead of trusting a paragraph.
Where a webhook provider wins
If you’re running a delivery-analytics product, or you need sub-second reaction to a failed message across millions of sends, polling is the wrong architecture and you should stick with a provider that streams receipts — Twilio and Sinch both publish real DLR webhooks, and buy one of them if delivery telemetry is the product rather than a supporting signal. Infrai doesn’t offer an outbound delivery webhook on the SMS surface, so the fastest you can know is your next poll.
For a login form, none of that is on the critical path: the code either gets typed or it doesn’t. What is on the critical path is everything that surrounds the send — the email fallback when a number keeps failing, the queue behind the poll worker, the error capture, the per-tenant usage line — and those are already on the same credential you just used, which is the one thing a single-purpose SMS vendor can’t hand you.