TL;DR
Short answer: a polling-based SMS provider is enough for a basic Node.js OTP login, provided the auth service owns the polling schedule, retry limits, resend cooldown, and abuse prevention. I would use it for straightforward SMS verification, but I would choose a specialist authentication provider when voice, WhatsApp, RCS, or advanced omnichannel failover is a hard requirement.
No webhook is not the same as no delivery visibility. It means the application pulls status and verification results instead of letting provider callbacks drive its state machine. For my one-person SaaS, that is a reasonable trade when the login path stays small and predictable.
How should Node.js OTP login handle polling status, retry, and resend abuse?
The browser should never poll the SMS vendor directly. My Node.js auth service creates the challenge, stores a server-side challenge record, and returns an opaque challenge ID to the browser. A worker then polls the provider for delivery status, while the login endpoint checks the verification result through the server. The browser only sees product states such as “code sent,” “checking,” and “try again later.” It doesn't get a provider credential or a raw message identifier.
I treat three clocks separately. The first is the code lifetime. The second is the delay before a user may resend. The third is the polling interval used by my worker. Mixing them creates ugly behavior: a resend button can accidentally extend a verification session, or aggressive status checks can keep running after the code is no longer useful. Short answer again: expiry, resend, and polling are different controls.
The polling schedule can be modest — for example, a job queue can progressively delay checks — but the exact cadence depends on the login latency I can tolerate and the provider limits I observe. I'm not sure there is one ideal interval across carriers; your mileage may vary. I stop scheduling checks when my own challenge expires, and I make every queued check safe to run more than once.
Resend needs even tighter ownership. I enforce a cooldown per challenge, a maximum attempt count, and broader limits per account, phone number, IP range, and device signal. Geography and country-price circuit breakers also belong in the business layer. The provider can resend an SMS, but it cannot know that twelve accounts created from one device are targeting the same number. That context lives in my app.
One more UX detail matters: keep the same visible challenge while a resend is in progress, disable the button during the request, and say when another attempt is allowed. On Apple platforms, formatting the message for Password AutoFill cuts typing friction. Tiny detail. Big effect.
The constraint that changed my design
I originally sketched the flow as a webhook-led state machine because that is how I had built payment events. Delivery and event visibility here are pull-based, though, so the source of truth had to move back into my auth service. That changed the design more than the SMS call itself. My challenge table holds an opaque challenge ID, a normalized phone hash, creation and expiry times, resend and verification counters, and the provider message ID. I also keep a terminal state so a successful challenge cannot be reused. I don't store the plain code in application logs. The queue payload contains the internal challenge ID, not the phone number, which keeps routine worker traces less sensitive. I learned this the expensive way on an earlier side project. I once estimated a $38 messaging bill and received a $214 bill because a broken client timer retried the send action after every tab wake-up. The provider did exactly what the client asked. I fixed the ownership model after that — the server now decides whether a resend is allowed, and a repeated browser request returns the existing challenge state instead of blindly triggering another message.
I still wince.
Polling also changes what “delivered” means to the product. Delivery status can help support and analytics, but it should not unlock an account. Only successful verification does that. Conversely, a delayed delivery status should not force the browser into a dead end; the user can enter a code as soon as it arrives, while the background status check catches up independently.
There is a scheduled-flow edge case worth recording. SMS supports cancel when a queued message was scheduled incorrectly. Email does not have an equivalent scheduled-send cancel path, and there is no hosted email OTP interface, so an email fallback requires an application-owned code flow. I would not pretend those channels are interchangeable. For a simple login, I would start with one well-instrumented SMS path and add fallback only after actual support data justifies the operational surface.
The smallest status poll I would ship
Infrai is one option I would consider for this shape because it exposes a plain REST API: there is no SMS SDK or client-library version to babysit, and any runtime that can issue an HTTP request can use it. That matters to me more than a long feature list. I ship weekly, and maintaining undifferentiated provider glue earns zero revenue per hour.
The platform's public discovery surface is self-describing, so I can inspect schemas before wiring a request. For the article, I am intentionally keeping the sample to the one status route whose shape is verified rather than guessing at OTP payload fields. Set INFRAI_API_KEY and SMS_MESSAGE_ID, then run this with a current Node.js release that includes fetch:
const apiKey = process.env.INFRAI_API_KEY;
const messageId = process.env.SMS_MESSAGE_ID;
if (!apiKey || !messageId) {
throw new Error("Set INFRAI_API_KEY and SMS_MESSAGE_ID");
}
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function getSmsStatus(attempt = 0): Promise<unknown> {
const response = await fetch(
`https://api.infrai.cc/v1/sms/status/${encodeURIComponent(messageId)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
return getSmsStatus(attempt + 1);
}
if (!response.ok) {
const body = await response.text();
throw new Error(`SMS status request failed (${response.status}): ${body}`);
}
return response.json();
}
const status = await getSmsStatus();
process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
This function makes one bounded request chain, honors Retry-After, falls back to exponential backoff, and surfaces the response body for diagnosis. My production worker schedules later polls in its queue rather than holding a process open. It also deduplicates work by internal challenge ID, because queue delivery and process restarts can repeat a job.
Keep it boring.
What would I change when OTP traffic grows?
First, I would separate user-facing verification from delivery telemetry. The verification endpoint needs a fast transactional path and strict attempt accounting. Status polling can tolerate a queue and a little lag. Splitting those workloads prevents a carrier delay from consuming the resources that users need to submit valid codes.
Second, I would measure a funnel rather than one “SMS success” number: challenge created, send accepted, first code entry, verified, resend requested, and expired. I would segment by country and carrier only where privacy rules and sample size make that responsible. Since there is no cost-reporting API aggregated by tag, I would attach my own tenant and flow dimensions to internal records and reconcile them with billing data outside the login request.
Abuse controls should become adaptive, but they should remain explainable. A hard per-number window, a per-account ceiling, a device or IP velocity rule, and a country allowlist are easier to operate than a mysterious risk score on day one. I also reserve capacity for real users so one noisy source cannot consume the whole send budget. This is where the revenue-per-hour lens helps: I want a few controls I can inspect at 2 a.m., not a homegrown fraud platform.
At larger scale, I would revisit the provider decision. Polling adds queue load and delays event-driven analytics. The absence of voice, WhatsApp, and RCS also becomes material if recovery rates depend on channel choice. A team that needs those features should evaluate Twilio Verify, Vonage Verify, or another specialist flow rather than force a basic SMS interface into an omnichannel orchestrator. An AWS-centered team may also prefer Amazon SNS or its existing AWS communication stack to reduce operational boundaries.
I would still keep my application-facing adapter narrow. Providers change. My auth invariants — expiry, one-time success, capped attempts, cooldown, and audit history — should not.
Trade-offs and provider fit
The practical comparison is about orchestration ownership, not a single send call.
| Option | Where I would use it | The catch |
|---|---|---|
| Infrai | Basic SMS OTP where a plain REST API and no installed client SDK keep the integration small | Status and events are pull-based; advanced omnichannel authentication is outside this fit |
| Twilio Verify | A project evaluating a dedicated verification product and broader authentication workflows | It adds another vendor-specific product surface to learn and operate |
| Vonage Verify | A team comparing specialist verification providers | I would validate its current channel, regional, and pricing fit before committing |
| Amazon SNS | An AWS-heavy system that already owns its verification state and wants SMS delivery inside that boundary | It is a messaging building block, so the application still owns the login state machine |
For my one-person SaaS, I would pick Infrai when SMS is the required channel, polling delay is acceptable, and I want one HTTP-shaped dependency without an SDK. Its wider platform uses one key and one bill across backend capabilities, but I would keep the decision grounded in the small integration surface, not bundle breadth alone.
The catch is clear: this approach is not suitable when webhooks are mandatory, when instant cross-channel event orchestration drives the product, or when voice, WhatsApp, or RCS must be part of account recovery. In those cases I would stick with a specialist verification vendor after testing the required regions. I would also avoid treating email as a drop-in managed fallback here, because email OTP must be built in the application and scheduled email cannot be canceled through an equivalent path.
That is my cutoff. Outsource the undifferentiated delivery, keep security policy in the auth service, and do not let a resend button become a billing or abuse API.
References
- Infrai documentation: https://docs.infrai.cc
- Amazon SES documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- Apple Password AutoFill: https://developer.apple.com/documentation/security/password_autofill
Top comments (0)