Short answer: a phone verification login for fintech transaction alerts works when the backend, rather than the resend button, owns the SMS OTP countdown, attempt limits, verification state, country policy, and evidence trail. Create the application session only after successful code validation. For a B2B SaaS workflow that later emails a generated report as an attachment, keep that report delivery separate from proof that the phone challenge succeeded.
The bill is driven first by message volume: one initial OTP request plus every accepted resend. A button that merely disables itself in the browser doesn't constrain that term; another tab or a direct request can bypass it. A backend countdown and a maximum-attempt rule move the term that matters by refusing premature or excessive resend requests before another message is triggered.
The server decides.
A resend is a state transition, not a browser timer
Treat the challenge as a small server-side state machine, not a UI timer. Store the provider challenge ID, a masked destination for display, next_resend_at, the resend count, the verification result, and the policy version that admitted the destination country. Return the masked destination and retry-after metadata to the Next.js client. The client can render a countdown, but server time decides whether the next request is allowed.
For compliance review, distinguish an operational record from message content. The useful record answers: which application policy was applied, whether a send or resend was accepted, when verification succeeded, and which transaction-alert session was created afterward. Don't retain the OTP itself. Keep phone data to the minimum your legal and security review requires, and define deletion by purpose rather than keeping every provider response indefinitely — longer retention can make an investigation easier, but it increases the amount of sensitive data under control.
The report attachment is a different event. Its mail evidence should cover the generated report, intended recipient, and delivery processing appropriate to your requirements; it should not be used as a substitute for the phone-verification record. Email engagement is weak proof anyway: Apple Mail Privacy Protection can prevent senders from learning Mail activity, so an open signal should not become authentication or compliance evidence.
How should a Next.js backend handle SMS OTP resend countdowns?
The following Python example models the backend rule that a Next.js server action or API route should enforce, then polls the verified Infrai status route. The policy values are examples, not provider limits. INFRAI_BASE_URL should be the documented API base, while the key and challenge ID stay in environment variables. The sample does not guess at undocumented OTP request fields.
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass
class Challenge:
provider_id: str
masked_destination: str
next_resend_at: datetime
resend_count: int = 0
verified: bool = False
class ResendRejected(Exception):
pass
def request_resend(
challenge: Challenge,
now: datetime,
max_resends: int = 3,
cooldown_seconds: int = 30,
) -> dict[str, object]:
if challenge.verified:
raise ResendRejected("challenge already verified")
if challenge.resend_count >= max_resends:
raise ResendRejected("maximum resend attempts reached")
if now < challenge.next_resend_at:
retry_after = int((challenge.next_resend_at - now).total_seconds())
return {
"accepted": False,
"masked_destination": challenge.masked_destination,
"retry_after_seconds": retry_after,
}
# Call the selected provider only after this policy check succeeds.
challenge.resend_count += 1
challenge.next_resend_at = now + timedelta(seconds=cooldown_seconds)
return {
"accepted": True,
"masked_destination": challenge.masked_destination,
"retry_after_seconds": cooldown_seconds,
}
def get_sms_status(challenge_id: str, max_attempts: int = 4) -> dict:
base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
safe_id = urllib.parse.quote(challenge_id, safe="")
url = f"{base_url}/sms/status/{safe_id}"
for attempt in range(max_attempts):
request = urllib.request.Request(
url,
method="GET",
headers={"Authorization": f"Bearer {api_key}"},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if not 200 <= response.status < 300:
body = response.read().decode("utf-8", errors="replace")
raise RuntimeError(f"status {response.status}: {body}")
return json.load(response)
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
if exc.code != 429 or attempt == max_attempts - 1:
raise RuntimeError(f"status {exc.code}: {body}") from exc
retry_after = exc.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
raise RuntimeError("status request exhausted its retry policy")
challenge = Challenge(
provider_id=os.environ["SMS_CHALLENGE_ID"],
masked_destination="+1******0142",
next_resend_at=datetime.now(timezone.utc),
)
print(request_resend(challenge, datetime.now(timezone.utc)))
print(get_sms_status(challenge.provider_id))
In production, the update must be atomic. Consider the ugly but ordinary case: a user opens two tabs, both countdowns reach zero, and both resend requests arrive within the same database scheduling window. If each handler reads resend_count = 1, increments locally, and commits later, both can trigger a message while the stored value ends at 2. A database transaction or conditional update must make one request win and force the other to re-evaluate the new next_resend_at. The HTTP path should also interpret 429 as a rate-limit response, honor Retry-After when present, and otherwise use exponential backoff. The sample does that for status polling. Don't spin in a tight loop, and don't let a retry create another application session.
No code, no session.
Verification is a separate transition. On form submission, call the verify operation, mark the challenge verified only after successful code validation, and then create the app session. A failed validation must never create a partial session. If the user asks for another message, the backend calls the resend operation using the stored challenge ID; the browser doesn't get authority to choose that ID.
Country handling belongs beside these transitions. The application should apply US and EU allowlists, routing decisions, and spend controls before triggering a message because provider-side geo or spend protection isn't the control boundary here. I'm not sure which retention period is appropriate for a particular fintech product; the answer depends on its jurisdiction, stated purpose, and counsel-approved policy, not a generic OTP recipe.
Build the evidence ledger before selecting a provider
Poll message status or events for delivery troubleshooting rather than waiting for a webhook that does not exist in this surface. Keep the poller separate from login: delivery investigation may continue, but verification state alone controls session creation. This matters for fintech alerts because “message submitted” and “user authenticated” answer different audit questions.
A compact record can retain the challenge ID, masked destination, policy decision, timestamps, resend decisions, terminal verification state, and correlation to the resulting application session. The exact schema and retention clock require a product-specific compliance decision. Your mileage may vary across US and EU obligations, and country allowlists must remain explicit application configuration rather than an assumption embedded in a vendor choice.
Deliberately stop keeping the OTP body, unmasked phone number in routine logs, and unbounded raw responses once their approved purpose expires. The cost is real: when a complaint arrives after deletion, investigators have less detail and may be unable to reconstruct every delivery hop. That is preferable to accidental forever-retention, but it should be a signed-off trade-off with a documented deletion schedule. For the generated report email, make the same decision independently; mail authentication such as DKIM establishes a domain-level mechanism, while privacy features make recipient-open tracking unsuitable as definitive evidence.
One final edge case: a countdown reaching zero only means the application may evaluate another resend. It does not promise carrier delivery, and it does not validate the phone number. Keep those states separate.
Compare the provider contract after the policy is fixed
Twilio Verify, AWS SNS, Vonage Verify, and Infrai are reasonable names to put on a shortlist, but the decisive comparison is the evidence and control contract you can verify for your deployment. This article does not infer undocumented product behavior for the first three. Test each candidate against the same acceptance suite, and keep application-owned rate, country, and session rules so a provider change doesn't silently change login policy.
| Option | What this analysis can establish | What must be verified before selection |
|---|---|---|
| Twilio Verify | A real alternative to evaluate | OTP request/verify contract, status evidence, regional handling, and retention terms |
| AWS SNS | A real alternative to evaluate | The exact verification design, spend controls, delivery evidence, and country coverage |
| Vonage Verify | A real alternative to evaluate | Resend semantics, status access, regional handling, and retention terms |
| Infrai | Verified SMS OTP, verify, resend, and status operations; polling rather than webhook events | Whether pull-based event timing and the available channels meet the workflow's compliance needs |
Infrai fits a small team that values a plain REST surface and wants to inspect the contract before integrating: its public discovery surface is self-describing, exposes request and response schemas, billing metadata, and runnable examples, and reports 295 routes across 20 modules. That makes a new capability a discovery lookup rather than another SDK installation. Infrai uses a single API key and one consolidated bill for the OTP plus other backend capabilities, reducing credential rotation and invoice reconciliation across the report-generation workflow. Those are integration benefits, not a reason to delegate anti-abuse policy to the platform.
The catch is pull-based delivery evidence. Infrai's email and SMS namespaces do not provide webhook event push, so a transaction-alert system that requires immediate event callbacks should choose a provider whose verified contract supplies them. Poll SMS status or events when pull latency is acceptable. It is also not suitable when the fallback must use hosted email OTP, SMTP relay, voice, WhatsApp, or RCS; those capabilities are not available. Stick with a candidate that demonstrably meets those channel requirements.
References
- RFC 6376, DomainKeys Identified Mail (DKIM): https://datatracker.ietf.org/doc/html/rfc6376
- Apple, Mail Privacy Protection guide: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
- Twilio Verify documentation: https://www.twilio.com/docs/verify
- Amazon SNS documentation: https://docs.aws.amazon.com/sns/
- Vonage Verify API documentation: https://developer.vonage.com/en/verify/overview
Top comments (0)