Short answer: for a marketplace app, keep the SMS OTP login API stateful, make resend an idempotent state transition, and treat delivery status as evidence that drives suppression rather than as proof that a user authenticated.
That rule sounds modest. It prevents a surprisingly expensive class of mistakes: a buyer requests five codes, a carrier rejects the number, and the account keeps trying the same destination because the system only knows that an HTTP request succeeded. The login flow needs two separate facts: the message provider accepted a send request, and the recipient actually received a usable code. Those facts arrive at different times.
I build RAG and agent features in Python, so I apply the same eval-driven habit here. Define the states first, test the transitions with recorded events, then put a thin HTTP adapter around them. Node.js is a perfectly reasonable edge runtime for the API; the state machine below is language-neutral and its reference implementation is Python so the behavior is easy to inspect.
How should a US/EU app shape its SMS OTP login API with polling after a failed delivery?
Start with a short-lived challenge record keyed by a random challenge ID, not by the phone number. Store a hash of the OTP, an expiry timestamp, a resend counter, and the last provider message ID. Keep the phone number normalized to the format your policy accepts, and keep the raw number out of application logs. A challenge can move through created, accepted, delivered, failed, verified, and expired; resend creates a new code while retaining a link to the previous attempt.
The client calls POST /login/challenges once. The response contains challenge_id, an expiry, and a coarse delivery state such as accepted. It then polls GET /login/challenges/{id} at a bounded interval, perhaps 2 seconds with a deadline around 30 seconds. Polling is for display and retry decisions. It must never authorize the session. Only POST /login/challenges/{id}/verify with a matching, unexpired code can do that.
When the user taps resend, the server should enforce a cooldown and an attempt ceiling. A repeated request with the same idempotency key returns the original resend result. A new key may issue a new code, but the old code becomes invalid immediately. This removes the race where two valid messages arrive out of order.
Here is a compact state core. It has no provider calls, which makes it suitable for unit tests and for a notebook-to-prod migration. Your transport adapter can map a provider webhook into mark_delivery.
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets
@dataclass
class Challenge:
phone: str
otp_hash: str
expires_at: datetime
state: str = "created"
resend_count: int = 0
provider_id: str | None = None
used_keys: set[str] = field(default_factory=set)
def digest(code: str, pepper: bytes) -> str:
return hmac.new(pepper, code.encode(), hashlib.sha256).hexdigest()
def new_challenge(phone: str, pepper: bytes, ttl_seconds: int = 300) -> tuple[str, Challenge, str]:
code = f"{secrets.randbelow(1_000_000):06d}"
challenge_id = secrets.token_urlsafe(18)
item = Challenge(phone, digest(code, pepper),
datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds))
return challenge_id, item, code # hand code to the SMS adapter, never to logs
def verify(item: Challenge, supplied: str, pepper: bytes) -> bool:
now = datetime.now(timezone.utc)
if item.state in {"verified", "expired", "failed"} or now >= item.expires_at:
item.state = "expired"
return False
if hmac.compare_digest(item.otp_hash, digest(supplied, pepper)):
item.state = "verified"
return True
return False
def mark_delivery(item: Challenge, status: str, provider_id: str) -> None:
item.provider_id = provider_id
item.state = "delivered" if status == "delivered" else "failed"
The production version needs a database transaction around resend and verify, plus an atomic counter for failed attempts. The code intentionally does not infer delivery from a send response. A provider message ID is a correlation handle, not an authentication signal.
What does a bounce or invalid recipient mean for an OTP challenge?
For a marketplace, recipient suppression is a compliance control and a cost control. A permanent carrier rejection, an invalid destination, or a consent violation should put the normalized address on a suppression list with the event type, source, timestamp, and retention rule. Do not silently turn every transient timeout into a permanent block. Keep a small event taxonomy: invalid_recipient, carrier_rejected, unreachable, expired, and user_reported_abuse are operationally different.
Email guidance from Google emphasizes sender authentication, low spam rates, and clear handling of unwanted mail; SMS programs have their own carrier and consent expectations. The common engineering lesson is to preserve evidence. Save the provider event ID, the challenge ID, the policy decision, and the version of the suppression rule that made the decision. That record lets a compliance reviewer answer “why was this recipient stopped?” without reading an application log.
Do not reveal the reason to an unauthenticated login client. Return a generic message such as “We couldn't send a code to that destination.” Internally, expose the specific reason to support tooling with access controls. This prevents account enumeration while still giving operators a useful queue.
A marketplace often has both buyers and sellers sharing a contact channel. Scope suppression to the destination and program where policy requires it, and make the scope explicit. A seller’s marketing opt-out should not automatically erase a security-login consent record, but a carrier-invalid number should block every program until it is corrected.
Which polling and retry behaviors survive real carrier timing?
Status polling should be boring. Return the latest state, updated_at, and retry_after_seconds; cap the number of polls server-side and client-side. A failed state can offer another-send action after cooldown. A delivered state can stop polling, but the verify endpoint still checks the code and expiry.
One trap I hit in an eval harness was treating accepted as success. The test passed because the mock provider returned 202, then the real callback arrived later with a permanent rejection. The fix was a contract test that replays callbacks in every order, including duplicate callbacks and a callback after expiry. It uses 12 fixtures, not a single happy path. I also keep a fixture where the user enters the first code after a second code has been issued: the expected result is a clean rejection and no session cookie, while the audit stream records the supersession. That one case catches a surprising number of accidental “last request wins” implementations, especially when a queue retries a job after a worker restart. Your mileage may vary with carrier latency, so measure p50 and p95 by region instead of choosing a universal timeout.
Keep the rule visible in code review: accepted is not delivered, and delivered is not verified.
Use exponential backoff for provider API retries, honor Retry-After, and attach an idempotency key to each outbound send. Never retry a verification request by sending another SMS. Rate-limit by account, IP, device fingerprint, and destination, with separate limits for code guesses and resends. Alert on sudden changes in invalid_recipient and carrier_rejected; those shifts can indicate an ingestion bug or abuse campaign.
How do compliance evidence and implementation cost change the API choice?
Evaluate an SMS OTP API on evidence flow before unit price. Ask whether it can emit signed delivery events, retain message IDs, expose regional routing, and document consent requirements. Ask how quickly those events reach your system and how duplicates are represented. A simple API that cannot produce an auditable event trail becomes a custom compliance project.
There are trade-offs. A hosted messaging service reduces carrier integration work but couples your evidence shape and retention controls to its webhook model. A direct carrier connection offers more control and more operations work. A self-hosted queue gives excellent replayability, yet your team owns delivery integrations and on-call coverage. This small table keeps the decision concrete:
| Approach | Evidence and routing control | Operations load | A reasonable fit |
|---|---|---|---|
| Hosted messaging API | Provider webhooks and regional options | Lower | Small teams with clear retention requirements |
| Direct carrier connection | Highest control, carrier-specific details | High | Regulated or routing-sensitive programs |
| Self-hosted queue plus adapters | Strong replay and local audit trail | Medium to high | Teams already operating messaging workers |
Stick with a hosted option when your team cannot staff that surface; choose direct integration when regulatory or routing requirements justify the extra ownership.
Node.js teams can expose the same three endpoints with their preferred web framework, while Python workers consume callbacks and run suppression jobs. Keep the contract in an OpenAPI document and generate client types from it. The implementation language is less important than stable states, replayable evidence, and a clear boundary between “message accepted” and “identity verified.”
Before shipping, walk through the checklist as a prose review: create a challenge with a five-minute expiry; resend twice with duplicate idempotency keys; deliver callbacks out of order; reject an invalid recipient; verify a correct code; reject a reused code; and inspect the audit record for each transition. Then run load tests against the polling endpoint, sample logs for phone-number leakage, and confirm that EU and US retention settings are applied by policy rather than by a hidden default.
References
- Google, “Email sender guidelines”: https://support.google.com/a/answer/81126
- CTIA, “Messaging interoperability and SMS/MMS industry commitments”: https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
- RFC 9457, “Problem Details for HTTP APIs”: https://www.rfc-editor.org/rfc/rfc9457
- RFC 6238, “TOTP: Time-Based One-Time Password Algorithm”: https://www.rfc-editor.org/rfc/rfc6238
Top comments (0)