A resend endpoint should be a small state machine, not a second login flow. For a healthtech marketplace seller signing in before viewing a new order, the useful contract is: one active code, a bounded resend window, an idempotent request key, and errors that tell the client what action is safe. The deciding constraint is integration effort. Put policy in one service, keep the Node.js transport adapter thin, and measure false rejects, delivery latency, and provider-independent outcomes before changing the policy.
Short answer: keep one active SMS OTP challenge, make resend idempotent, return explicit API errors for cooldown and expiry, and test those transitions before connecting a delivery provider.
Keep it boring.
How should SMS OTP API errors be handled?
The first implementation I usually see is a button that calls POST /otp/resend and generates another random value every time. I initially thought that was harmless. It creates a nasty race instead: two taps can produce two valid-looking messages, while only one code can sensibly be accepted. The seller enters the earlier message, gets rejected, and reports a broken login.
Treat the request as a transition on a login challenge. A resend within the cooldown returns the existing challenge metadata and a retry time. A resend after the cooldown creates a replacement code, invalidates the previous code, and increments an attempt counter. A request after the challenge has expired returns a fresh-login error rather than silently reviving it.
The response should not reveal whether a phone number belongs to an account. Keep the public response shape stable for known and unknown recipients, and record the real reason in protected logs. This is a security boundary, not cosmetic API design.
Five failure states are worth making explicit:
Use stable application errors even if the underlying SMS service changes. These are the states the client needs to handle:
| State | Client action | Server decision |
|---|---|---|
cooldown |
Wait until retry_after
|
Reuse the active challenge |
rate_limited |
Stop and show a generic retry message | Apply phone, account, IP, and device limits |
expired |
Start a new login challenge | Reject the old challenge |
delivery_unknown |
Offer a later retry path | Keep the outcome uncertain; do not claim delivery |
verified |
Continue to the order view | Make the challenge one-time use |
delivery_unknown matters. An accepted API request is not proof that a carrier delivered a text. The user-facing message can say that a code was requested without promising arrival. That distinction prevents the UI from turning an asynchronous delivery system into a false guarantee.
A policy function you can test before wiring Node.js
Keep generation, persistence, and transport behind interfaces. The policy itself can be tested with a table of timestamps, attempts, and challenge states. This is the notebook-to-prod checkpoint: if the rule is difficult to exercise without a live SMS integration, the boundary is in the wrong place.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass
class Challenge:
status: str
created_at: datetime
last_sent_at: datetime | None
resend_count: int
max_resends: int = 3
ttl: timedelta = timedelta(minutes=10)
cooldown: timedelta = timedelta(seconds=30)
def decide_resend(challenge: Challenge | None, now: datetime) -> str:
if challenge is None:
return "start_challenge"
if challenge.status == "verified":
return "verified"
if now - challenge.created_at >= challenge.ttl:
return "expired"
if challenge.resend_count >= challenge.max_resends:
return "rate_limited"
if challenge.last_sent_at and now - challenge.last_sent_at < challenge.cooldown:
return "cooldown"
return "resend"
clock = datetime.now(timezone.utc)
challenge = Challenge(
status="pending",
created_at=clock - timedelta(minutes=2),
last_sent_at=clock - timedelta(seconds=8),
resend_count=1,
)
assert decide_resend(challenge, clock) == "cooldown"
In a Node.js service, the route handler should parse the request, derive a server-side identity for rate limiting, call an equivalent policy module, and translate the result into the public response. It should not own code generation or decide delivery semantics. The database operation that replaces a challenge needs a transaction or an atomic conditional update; otherwise two concurrent requests can both pass the cooldown check.
How do you handle errors without making the login brittle?
Separate retryable errors from terminal ones. A timeout from the transport path may justify a later retry, but a policy rejection should not trigger an automatic resend. Return a machine-readable code, a human-safe message, and retry_after only when a retry is actually allowed. Avoid exposing carrier response text: it is unstable, sometimes sensitive, and rarely useful to a browser.
The adapter should also accept an idempotency key supplied by the client. Store the key with the challenge decision for a short retention period. A duplicated request then returns the original decision instead of generating another code. This is especially important when a mobile connection drops after the server commits but before the client receives its response.
Log a correlation ID, challenge ID, policy result, and latency. Do not log the OTP, full phone number, or message body. For evaluation, replay fixed cases: double taps, simultaneous requests, an expired challenge, a transport timeout, and a successful one-time verification. I care more about these traces than a happy-path dashboard because they expose the integration contract that future adapters must preserve.
What should be measured before copying the policy?
Start with a small eval harness that checks invariants: no challenge accepts twice, no resend bypasses cooldown, expired challenges stay rejected, and unknown recipients receive the same public shape as known ones. Add delivery latency and user retry counts as observational metrics, but do not treat either as proof of verification.
The right cooldown and attempt limits depend on the threat model, carrier behavior, and seller workflow. A seller waiting for a time-sensitive order may need a different support path from a consumer login. Make those choices configuration, test them against recorded synthetic events, and review them with security and operations. The portable design is the state machine and its evidence, not a particular SMS provider or a magic number. The tradeoff is deliberate: a self-hosted policy gives control over state and logs but leaves delivery operations to your team; a managed transport reduces integration effort but cannot remove the need for application-level idempotency and evaluation.
A resend API is finished when its failure states are boring: clients know when to wait, when to restart, and when to stop. That clarity reduces integration work more reliably than adding another endpoint.
References
- OWASP, Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP, Transaction Authorization Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Transaction_Authorization_Cheat_Sheet.html
- RFC 9420, Messaging Layer Security: https://www.rfc-editor.org/rfc/rfc9420.html
- Apple, Mail Privacy Protection guide: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
Top comments (0)