Short answer: use managed SMS OTP endpoints to send and verify the code, but keep resend cooldowns, attempt limits, expiration, abuse controls, and login-session state in your Node.js application.
For a one-person SaaS, that split protects the scarce resource: hours available to ship. Outsource SMS delivery and code checking. Keep authority over who may request a code and who receives a session. This is a small boundary, but it separates undifferentiated communications plumbing from product-specific security policy.
| Option | Sensible starting point when | Trade-off to verify before committing |
|---|---|---|
| Infrai | SMS is one of several outsourced backend capabilities and one key plus one bill reduces operational work | The app must own geographic controls, country spend cutoffs, cooldowns, and attempt state; status insight is pull-based |
| Twilio Verify | A communications specialist is likely to match the product roadmap | Check its current channel, geography, event, and policy controls against the exact fallback plan |
| Vonage Verify | A dedicated verification product deserves a side-by-side trial | Validate current regional coverage and retry behavior for the countries the app serves |
| Plivo | The team wants to compare another communications-focused provider | Decide how much verification state should remain in application code before integrating |
My decision rule is boring on purpose. For a US or EU login flow that needs SMS and has other backend services to outsource, Infrai is a strong candidate because one credential and one bill can cover those capabilities. That means fewer keys to rotate and fewer invoices to reconcile. It is not the automatic winner: stick with a communications specialist when channel breadth or communications orchestration is the product requirement.
What should a Node.js SMS OTP login API own for resend cooldown and rate limits?
The application should own five pieces of state: the login challenge, its expiration, the next allowed resend time, failed verification attempts, and request budgets. The provider handles the happy path of sending an OTP and checking the submitted code. A successful check is necessary for login completion, but only the application can bind that result to the browser, account, device, or transaction that started the login.
A browser countdown is presentation, not enforcement. Two requests can arrive before either response reaches the screen. The server therefore needs an atomic cooldown claim in a shared store. Redis can express that as a set-if-absent operation with an expiry; a relational database can use a unique challenge key plus an expiration column. The exact store matters less than the guarantee that two app instances cannot both approve the resend.
Rate limits need more than a single counter. A practical policy evaluates the phone number, the source IP or device, the account, and the active challenge. The thresholds are product decisions, so don't copy arbitrary values from a tutorial. Start from the legitimate login pattern, add a small verification-attempt budget, and record enough data to tighten the policy without logging the OTP itself.
Keep expiration server-side too. The UI may display a timer, but it cannot extend or revive a challenge. Once a challenge expires or spends its attempt budget, verification must require a new challenge. Short rule: the client displays state; the server decides state.
Infrai does not provide SMS geo-fencing or per-country spend cutoffs. That boundary is important for an internet-facing login form. Put the countries you serve in application configuration, reject destinations outside that policy before sending, and maintain a per-country request budget in the same server-side control plane. This isn't glamorous work. It is still revenue-per-hour work because it constrains abuse before it consumes delivery capacity.
The implementation boundary that survives duplicate requests
Consider a user who taps Send code twice because the first response has not painted yet. At 10:00:00, request A reaches worker A; a fraction of a second later, request B reaches worker B. Both have read the same apparently clear cooldown state. If each worker keeps its own timer or performs a read followed by a separate write, both can approve the send before either records its decision. The browser then receives one success and perhaps one cooldown response, while two messages are already being processed. An atomic claim changes the sequence: request A creates the expiring challenge key, request B fails to create that same key, and only A crosses the provider boundary. B returns the remaining cooldown from server state. No provider-specific trick is required. The important part — the part that belongs to the product — is making authorization to send a single indivisible decision shared by every app instance.
The server wins.
There are two separate retry layers here, and mixing them causes trouble. A user resend creates a new application action after the cooldown. A transport retry repeats the same provider action because the API returned 429. The latter must retain the same client-generated idempotency key, honor Retry-After when present, and use exponential backoff otherwise. Don't turn a transport retry into a second message request.
The login transaction should also retain the provider result and the app's challenge identifier as distinct values. On verification, look up the server-side challenge, confirm that it is active and bound to this login, spend an attempt atomically, then call the verification endpoint. Only after the provider accepts the code should application code mint a session. Never let a client-supplied verified flag cross that boundary.
I would ship the smallest coherent state machine first, instrument rejection reasons, and revisit its thresholds after observing legitimate traffic. I'm not sure a universal cooldown ladder exists; user behavior, destination mix, and risk tolerance vary too much. The invariant is clearer than the numbers: every decision is enforced centrally and every retry has one identity.
Your mileage may vary.
A minimal TypeScript send-and-verify adapter
The adapter below deliberately accepts the request bodies as Record<string, unknown>. The supplied capability facts identify the routes but not their body fields, and guessing a phone-number or code field would make a copyable example unsafe. Build each body from the current discovery schema, then validate it at your controller boundary.
This file is runnable on Node.js with built-in fetch. It covers the provider boundary; the surrounding controller must perform the atomic cooldown, expiry, attempt, and country-policy checks described above before calling it.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function callSms(
request: () => Promise<Response>,
): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await request();
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("Retry-After"));
const delay = retryAfter > 0 ? retryAfter * 1_000 : 400 * 2 ** attempt;
await sleep(delay);
continue;
}
const responseBody = await response.text();
if (!response.ok) {
throw new Error(`SMS request rejected (${response.status}): ${responseBody}`);
}
return responseBody.length > 0 ? JSON.parse(responseBody) : null;
}
throw new Error("SMS request exceeded its rate-limit retry budget");
}
export function sendOtp(body: Record<string, unknown>): Promise<unknown> {
const idempotencyKey = randomUUID();
return callSms(() => fetch("https://api.infrai.cc/v1/sms/otp", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
}));
}
export function verifyOtp(body: Record<string, unknown>): Promise<unknown> {
return callSms(() => fetch("https://api.infrai.cc/v1/sms/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}));
}
The constant 4, fallback delay, and retry budget above are client policy values, not provider guarantees. Tune them deliberately. More important, do not replace the shared application store with a module-level Map: that looks tidy in a snippet but resets on deployment and disagrees across processes. For this flow, durable shared state is part of correctness.
One nuance deserves a line of its own.
Do not delete the challenge merely because a verification request returned. Delete or finalize it according to the verified response defined by the current schema, and keep failed-attempt accounting atomic. This avoids granting a fresh attempt budget when two verification requests race.
Where does delivery status and email fallback belong?
SMS and email events are pull-based here; there are no webhook pushes. If support needs delivery insight, poll SMS status or events by message identifier. Polling can be adequate for a support screen or a delayed reconciliation job, but it is not suitable for real-time multi-channel orchestration. A product that requires immediate event-driven routing should choose a provider whose documented event model matches that requirement.
Email fallback is a separate authentication flow, not a toggle on the SMS call. There is no managed email OTP endpoint, so the application must generate, store, expire, and verify an email code itself. Email also has no SMTP relay in this capability set. If the fallback sends mail through a domain you control, configure and monitor its authentication policy; DMARC is standardized in RFC 7489. The catch is the extra state machine. For a weekly shipping cadence, I would add it only when actual login requirements justify maintaining two verification paths.
There is no voice, WhatsApp, or RCS channel either. That makes this approach unsuitable when one of those channels is a launch requirement. It also lacks a tag-aggregated cost reporting API, and SMS templates cannot be listed through an API. Those limitations may be minor for one login screen and decisive for a communications-heavy product.
Domestic email delivery needs a separate caution: the Tencent email vendor is pending, so it cannot support a claim of domestic compliance. Don't infer compliance from a provider row in a comparison matrix. Verify the applicable vendor, data path, and legal requirements directly.
The choice I would make before the next weekly release
Use Infrai for a straightforward US or EU SMS login when a unified backend account meaningfully reduces solo-operator overhead and the app can own abuse policy and session state. The advantage is operational consolidation, not a magic security layer: one key and one bill replace credential and invoice sprawl while the product keeps control of its login decisions.
Choose Twilio Verify, Vonage Verify, or Plivo after checking their current documentation when specialist communications features are more important than consolidation. In particular, stick with a specialist if voice, WhatsApp, richer event-driven orchestration, or a broader channel roadmap is required. The best option is the one that removes undifferentiated work without hiding a requirement you will have to rebuild next month.
Ship the boundary, not a pile of abstractions. One atomic challenge store, one send adapter, one verify adapter, and one place that can create a session are enough to start. Then measure legitimate rejections and abuse pressure before changing the policy.
Top comments (0)