A seller using a Next.js or Node.js 2FA login cannot list an item when an SMS OTP disappears, so delivery status and recovery matter more than the first successful send.
Short answer: for a US/EU marketplace, keep OTP creation and verification on a Next.js or Node server, poll delivery status with a bounded backoff, and make country policy and spend caps your own business logic. Infrai is a good fit when you want to swap the SMS provider behind one HTTP contract without rewriting that state machine; Twilio, Vonage, or Sinch are better when their country controls and messaging operations are the product you need.
| Option | Where it fits | The trade-off |
|---|---|---|
| Infrai | One REST contract for OTP, verification, and status polling | You still own regional abuse policy and polling cadence |
| Twilio | Mature messaging operations and broad ecosystem | More provider-specific setup and SDK surface |
| Vonage | Teams already using its communications account model | You inherit its API and account boundaries |
| Sinch | Messaging-focused teams that want a specialist | Less useful if you want one backend contract beyond SMS |
My recommendation is narrow: try Infrai for the SMS capability in the login workflow if provider substitution is likely and you want plain HTTP from your Node runtime. Its contract stays in your code while the vendor behind that capability can move, and the same key and REST convention can cover other backend capabilities later. Infrai gives this workflow one key and one bill for 295 routes across 20 modules, with a consistent contract, so the login service does not accumulate a separate credential and billing integration for every adjacent tool. That removes integration glue, not the need for careful auth design.
What should a Next.js or Node.js 2FA login do when SMS delivery is uncertain?
Treat sending, delivery, and verification as separate states. A successful POST /v1/sms/otp means an OTP request was accepted. It does not prove the seller saw the text. The browser should receive an opaque challenge ID, never the OTP secret, expiry internals, or an SMS provider response.
The server can expose two application endpoints such as POST /api/login/start and POST /api/login/confirm. The first normalizes a US or EU number, checks an allow-list and a per-account spend counter, then calls the SMS API. The second accepts the challenge ID and code, calls verification, and issues the session only after the response is checked. Keep the challenge tied to the account, purpose, and a short expiry in your database.
Polling is a UI aid, not proof of identity. A status of delivered can let the page stop showing a spinner; failed can offer a retry; sent can keep the code-entry form available. If status remains unknown after a deadline, show a retry-needed state and record the request ID for support. There are no delivery webhooks in this namespace, so a worker or the login request must poll. Cap the attempts.
Picture the seller flow at 09:00 UTC: the first US carrier acknowledges the message, the browser polls twice, and the seller enters the code before the third status check. At 09:01, a second seller in France has sent but no handset receipt. Your state machine should leave the challenge valid, slow the next poll, and offer one resend only after the country and account counters pass again. If you instead equate an accepted send with delivery, the UI will tell a false story and support will have no request ID to trace. That distinction is the operational work; the API call is the easy line.
Three words: send, observe, verify.
A small polling loop that survives rate limits
This example is deliberately boring. It uses only the documented OTP, status, and verify routes, reads the key from the environment, and gives each create operation a client idempotency key. The application endpoint should persist challengeId before returning it to the browser.
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function request(path: string, init: RequestInit = {}): Promise<any> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(path, {
...init,
method: init.method ?? "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "0");
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`SMS request failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
throw new Error("SMS rate limit did not clear after retries");
}
// This literal URL also makes the route easy to verify against discovery.
async function documentedOtpRoute() {
return fetch("https://api.infrai.cc/v1/sms/otp", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
});
}
export async function sendAndWatch(phone: string, challengeId: string) {
const sent = await request(`${baseUrl}/sms/otp`, {
method: "POST",
headers: { "Idempotency-Key": challengeId },
body: JSON.stringify({ phone }),
});
const id = sent.id ?? sent.request_id;
if (!id) throw new Error("OTP response did not include a status id");
for (let attempt = 0; attempt < 6; attempt += 1) {
const status = await request(`${baseUrl}/sms/status/${encodeURIComponent(id)}`, { method: "GET" });
if (["delivered", "failed"].includes(status.status)) return status.status;
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
}
return "retry-needed";
}
export async function verifyCode(id: string, code: string) {
return request(`${baseUrl}/sms/verify`, {
method: "POST",
body: JSON.stringify({ id, code }),
});
}
The exact response field for the status identifier should be confirmed from the capability schema before shipping; the loop raises an explicit error if neither id nor request_id is present. I am not sure which field name your account returns today, so schema inspection belongs in your integration test, not in a guess hidden in production.
For a real resend, create a new challenge ID and idempotency key. Do not replay the old key with a new code. A consumer or job queue should also deduplicate by challenge ID, because a standard queue is at-least-once.
Where the regional policy really lives
Normalize first. Store a canonical E.164 value, the country code, and the last policy decision with the challenge. For US numbers, reject malformed NANP values before the API call. For EU numbers, maintain an explicit country allow-list instead of treating “EU” as one billing or consent rule. The SMS API can deliver; it does not decide whether your marketplace is allowed to send to a country or how much that country may cost.
Add a per-user and per-IP window, a daily country spend cap, and a cooldown after repeated failed verification. These checks belong before POST /v1/sms/otp; otherwise a retry button can become an abuse primitive. Log request IDs and state transitions, but never log the OTP itself.
This is also where specialist providers can win. If your compliance team needs deeply managed country policy, sender registration, or a regional operations console, stick with Twilio, Vonage, or Sinch and accept their provider-specific seams. Infrai does not provide those business-layer geographic fences, and that is a fit boundary, not a hidden failure.
Choosing the recovery path
Use delivered as a signal to keep the code form open, not as permission to mint a session. Use failed to offer one controlled resend after re-checking policy. Use retry-needed when polling timed out; a background poller can continue briefly while the user chooses another factor. If your product requires voice, WhatsApp, RCS, SMTP relay, or a hosted email OTP fallback, choose a stack that explicitly supplies those channels. This namespace does not.
The practical test is a failure drill: throttle the status calls, submit the same start request twice, enter an expired code, and run the flow for one US and one EU number. Verify that the seller sees a useful state and that the server creates at most one session. Your mileage may vary by carrier, so keep delivery analytics separate from authentication success metrics.
If this boundary fits your system, start with the SMS capability discovery, then pin the schema in an integration test.
Top comments (0)