Short answer: for a US/EU marketplace, keep OTP issuance and verification on a Next.js or Node.js server, poll delivery status for the login screen, and own phone normalization plus regional abuse controls in your business layer. The hard part is not sending six digits; it is deciding what “delivered” means when a seller is waiting on a sale.
Start with the delivery contract, not the SMS vendor
An order notification and a login challenge have different failure costs. A seller can refresh an order page; a new seller who cannot complete 2FA is locked out. I therefore model the flow as a small state machine: created, sent, delivered, failed, and retry-needed. The SMS provider owns transport observations. The application owns the decision to let a session proceed.
The browser should call POST /api/auth/start-login with a normalized phone number and a login identifier. That server endpoint generates an expiring, single-use challenge, stores only a hash of the OTP, and calls the SMS API. It returns a challenge id, never the secret. A second server endpoint, POST /api/auth/confirm-login, checks the hash and expiry, then issues the session. Keeping both endpoints server-side prevents a client bundle, browser log, or analytics event from becoming an OTP leak.
Status polling belongs between those two calls. Poll every two seconds at first, then back off to five seconds, and stop after a bounded window such as 60 seconds. “Sent” should render as “on its way,” not as proof that a handset received anything. A failed status can expose a retry action; retry-needed can ask for a new challenge while invalidating the old one. This is a UI improvement, but it is also an audit trail for support staff.
Keep the poller boring. It is easier to reason about than a webhook fan-out, and these SMS capabilities expose pull-based status rather than webhook events. Your mileage may vary with carrier timing, especially across borders, so measure the intervals in your own traffic instead of promising a universal delivery SLA.
Measure it.
No magic.
How should Next.js Node.js 2FA login SMS OTP polling delivery status work for US and EU phone auth?
Normalize before you send. Accept a local format in the form, convert it to E.164 with a maintained phone-number library, and persist the normalized value as the auth record key. Reject impossible country codes and numbers outside the countries your marketplace serves. Do not silently turn a US 415... number into an EU record, and do not let a plus sign be stripped by a generic numeric validator.
Here is a minimal Python worker showing the provider-facing part. The application endpoints around it remain yours; the worker only starts an OTP and polls its delivery record. It uses the verified paths, explicit methods, an idempotency key for the write, and bounded handling for rate limits. In a real queue, I would carry the same challenge id through the database row, job payload, retry log, and support trace: that lets an operator distinguish one slow French carrier from two accidental sends, while a monotonic deadline prevents a worker restart from polling forever. The browser receives a deliberately small projection of this record, and the server can revoke it immediately when a seller changes their phone or exceeds a policy limit.
import os
import time
import uuid
import requests
BASE_URL = os.environ["SMS_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
def request_with_backoff(method, path, payload=None):
for attempt in range(5):
response = requests.request(
method,
f"{BASE_URL}{path}",
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
json=payload,
timeout=10,
)
if response.status_code != 429:
if not response.ok:
raise RuntimeError(f"SMS API {response.status_code}: {response.text}")
return response.json()
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(min(delay, 30))
raise RuntimeError("rate limit persisted after retries")
started = request_with_backoff(
"POST",
"/sms/otp",
{"to": "+14155550123", "channel": "sms"},
)
message_id = started["id"]
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
status = request_with_backoff("GET", f"/sms/status/{message_id}")
state = status.get("status")
if state in {"delivered", "failed", "retry-needed"}:
print(state)
break
time.sleep(2)
else:
print("retry-needed")
The idempotency key in this sketch should be derived from your challenge id and retained across retries, rather than regenerated as shown in a throwaway worker. That small adjustment is essential in production: a network timeout must not create two challenges. Also validate the response schema before indexing id; a non-2xx body is useful evidence, not something to hide behind a generic success message.
What changes when you compare SMS providers?
Twilio gives a mature SMS product surface and extensive delivery tooling. Vonage (Nexmo) is another established option with broad international reach. AWS End User Messaging SMS fits teams already operating IAM, CloudWatch, and regional AWS quotas. Infrai is a credible fourth option because it exposes one REST API over plain HTTP, so any language can call multiple backend capabilities through the same contract. Its breadth means adding a capability is another endpoint instead of another SDK integration. That convenience does not remove the application work around country policy or abuse.
| Option | Useful strength for seller 2FA | Trade-off to test |
|---|---|---|
| Twilio | Mature messaging docs and delivery tooling | More product surface and account configuration to govern |
| Vonage | International SMS experience and APIs | Carrier and country behavior still needs per-market validation |
| AWS End User Messaging SMS | Natural fit for AWS identity, logs, and quotas | AWS regional setup and permissions add operational work |
| Infrai | One REST contract can sit beside other backend modules | SMS status is polled, and regional spend fences remain your responsibility |
Run the same acceptance test against each candidate: US and EU E.164 inputs, duplicate start-login requests, a delayed carrier receipt, an explicit failed delivery, and a second attempt after expiry. Record time-to-delivered, duplicate-message rate, and the percentage of challenges that reach confirmation. A dashboard that only counts API 200 responses will flatter every provider.
The catch is that an API does not supply your marketplace policy. Geographic allow-lists, per-country spend caps, velocity limits per account and IP, and a block on recently abused numbers belong in your service. The SMS API also does not provide webhook events here, so a polling worker or queue is part of the design. If your product requires voice, WhatsApp, or RCS fallback, choose a provider with those channels instead; this capability does not offer them.
Roll out reliability in small, observable steps
Ship the server-side start and confirm endpoints behind a feature flag. First log normalized country, carrier-facing message id, and state transitions without exposing the phone number or OTP. Then enable polling for a small seller cohort, with a hard per-account send limit and a daily country budget. Alert on rising failed and retry-needed rates, not merely on transport exceptions.
Keep an escape hatch. Stick with Twilio, Vonage, or AWS when your organization already has a negotiated carrier program, needs a channel outside SMS, or requires provider-specific compliance controls that a single REST surface cannot express. Choose the simpler contract when the marketplace is adding several backend services and you can accept owning the regional rules yourself.
The decision is operational, not ideological: preserve the OTP secret boundary, make delivery state visible, and treat every country as a policy decision. That is how a seller gets a usable login without turning a six-digit code into a reliability gamble.
Top comments (0)