Short answer: choose the smallest 2FA login stack that can prove what happened after an SMS OTP request, not the API with the lowest headline rate. For a beginner SaaS serving the US and EU, that usually means one replaceable messaging adapter, an application-owned suppression ledger, bounded status polling, strict resend rules, and a non-SMS recovery path. Compare providers only after those constraints are written down.
The cheap send is easy. The expensive part is an account owner who can't sign in, a retry loop that sends several valid codes, or a support team that has no honest answer beyond "the API accepted it." I've learned to distrust green dispatch metrics when dealing with filtering and delivery gaps — acceptance is merely the start of the login attempt.
Keep that distinction sharp.
Start with the login deadline, not a vendor matrix
An OTP flow has several clocks, and your application owns all of them: code expiry, resend eligibility, polling duration, rate-limit recovery, and the point at which the UI offers another route. Write those clocks as policy before shopping for an SMS API. If they are buried in a provider SDK, switching later becomes an authentication migration rather than an adapter change.
The core record should be a login attempt, not a message. Give each attempt an opaque ID; attach every dispatch, provider message ID, status observation, and verification result to it; and allow only one code generation to be active according to your policy. A resend may create another dispatch, but it must not silently create an unrelated authentication ceremony. That detail matters when a delayed first message arrives after a faster second one.
I also separate three meanings that teams often compress into success: the provider accepted a request, the delivery channel reported a terminal outcome, and the user proved possession by entering the code. They belong in different fields. Otherwise an operations dashboard can claim excellent delivery while the verification funnel collapses, or it can blame the carrier for codes that users received but entered after expiry.
Design the fallback at the same time. SMS is not suitable as the only recovery route for an account whose phone is lost, reassigned, or unavailable while traveling. For higher-impact accounts, an authenticator app, passkey, recovery code, or assisted recovery procedure may deserve priority over SMS. The right alternative depends on the threat model and the support model; I'm not sure a universal channel ranking is useful without both.
Where low-cost SMS OTP stacks become costly
Delivery failures rarely stay inside a neat messaging boundary. Consider one ordinary race: attempt A creates code A and dispatch A, but its delivery state remains pending when the UI enables resend. The user taps once, attempt B creates code B, and dispatch B reaches the phone first. While the user types code B, a delayed worker polls dispatch A, sees another nonterminal state, and reschedules itself; then code A arrives and moves to the top of the notification list. If verification accepts only the newest code, the user copies A and gets rejected. If verification accepts every unexpired code without binding it to the attempt, the security window quietly widens. If the resend handler lacked idempotency, a double tap may have created attempt C as well. Nothing in this sequence requires a broken API. The messaging service can accept every request and report every transition correctly while the login experience still fails, because the application never decided which attempt owned the ceremony. The fix is a deliberate rule: define whether resend rotates or reuses a code, serialize that transition per account and challenge, make the UI display the attempt state it actually owns, and ensure late delivery evidence updates observability without reopening a closed authentication decision.
It gets messy fast.
Rate limits create a similar trap. A concrete response worth designing around is 429: if every worker retries on the same schedule, they synchronize and create another burst. Honor the provider's retry guidance when present, add jitter, and put a hard deadline on the job. Don't let a delivery poll live longer than the login attempt it represents.
Suppression is part of that control loop, but a suppression ledger is not a dumping ground for every ambiguous outcome. Store a normalized destination fingerprint or another privacy-conscious lookup key, the channel, a reason category, the evidence source, and a review or expiry policy. A definitive opt-out should block dispatch immediately. A temporary or unknown delivery state should follow a different policy; treating uncertainty as permanent suppression can lock out a real user, while treating every hard signal as transient can keep sending into a dead destination.
Email fallback has its own signal problem. Apple's Mail Privacy Protection can download remote content in the background, so an open event cannot honestly prove that the person saw an emailed code. Use verification completion, explicit delivery events, and bounce or complaint inputs according to their actual meaning. Vanity telemetry is dangerous here because it makes the fallback look healthier than it is.
Privacy and compliance constraints should shape the ledger before launch. Decide which fields are required for authentication and abuse defense, who can inspect them, how long each class of record is retained, and how deletion propagates. US and EU traffic should be tested as distinct routes because sender requirements, consent expectations, and operational review can differ. The exact obligations depend on the countries, message type, and business relationship, so resolve them with current provider documentation and qualified counsel rather than copying a generic checklist.
What should a beginner US/EU SaaS require from an SMS OTP API?
Require evidence and control, not a long feature page. The API must return a stable message identifier, expose delivery state through polling or callbacks, document which states are terminal, explain rate-limit behavior, and make sender eligibility visible before production traffic begins. Your adapter should preserve the raw provider state for diagnosis while mapping it into a small internal vocabulary for product logic.
The minimum evaluation is practical:
| Constraint | Test before selection | Reject or redesign when |
|---|---|---|
| US and EU reach | Send controlled test traffic on every planned route and sender type | A region cannot be validated before launch |
| Status evidence | Reconcile accepted requests with later states using stable IDs | States are undocumented or cannot be correlated |
| Suppression input | Identify which opt-out and delivery signals can feed your ledger | The application cannot block a destination before dispatch |
| Retry control | Exercise rate limiting and delayed status transitions | The client is expected to retry without bounded guidance |
| Data handling | Map destination, content, logs, region, retention, and deletion | Required handling conflicts with your privacy policy |
| Exit cost | Implement the same internal contract with a fake adapter | Provider concepts leak throughout login and user tables |
Price comes after this gate: compare the complete bill shape, including sender setup, regional requirements, status queries, support, and fallback traffic, and confirm minimum commitments or free allowances directly in the current terms. A tiny per-message difference can be irrelevant if support must manually repair opaque attempts. Your mileage may vary because destination mix and authentication volume change the result.
The catch is that a single messaging provider is not suitable when contractual separation, regional routing control, or demonstrated failover is a hard requirement. In that case, start with two adapters behind the same internal contract and define routing ownership explicitly. Stick with one adapter when the team is small and cannot continuously test two delivery paths; unused failover code is not resilience.
Model suppression and polling as one state machine
The browser should poll your application, never the messaging provider. That keeps credentials off the client, prevents provider states from becoming UI contracts, and gives the backend one place to enforce authorization and polling cadence. MDN documents the browser's Fetch API; the resource it calls here is your opaque login-attempt status, not a vendor message resource.
The backend can stay deliberately boring. This Python sketch omits storage and cryptography so the state transitions are visible; Gateway is an internal interface implemented by whichever adapter passes the evaluation.
from dataclasses import dataclass
from enum import Enum
from random import uniform
from time import monotonic, sleep
from typing import Protocol
class Delivery(Enum):
PENDING = "pending"
DELIVERED = "delivered"
NOT_DELIVERED = "not_delivered"
UNKNOWN = "unknown"
class Gateway(Protocol):
def check_delivery(self, message_id: str) -> Delivery: ...
@dataclass(frozen=True)
class PollPolicy:
budget_seconds: float
first_delay_seconds: float = 1.0
maximum_delay_seconds: float = 8.0
def poll_delivery(
gateway: Gateway,
message_id: str,
policy: PollPolicy,
) -> Delivery:
deadline = monotonic() + policy.budget_seconds
delay = policy.first_delay_seconds
while monotonic() < deadline:
state = gateway.check_delivery(message_id)
if state in {Delivery.DELIVERED, Delivery.NOT_DELIVERED}:
return state
sleep(delay + uniform(0.0, delay * 0.2))
delay = min(delay * 2, policy.maximum_delay_seconds)
return Delivery.UNKNOWN
UNKNOWN is intentional. It means the observation budget ended without a terminal signal; it does not mean delivery failed, and it should not automatically poison the suppression ledger. The UI can stop polling, preserve the attempt for reconciliation, and offer the recovery path allowed by policy. Small distinction. Big effect.
Status callbacks can reduce repeated reads, but polling still has a place while a person is waiting on a login screen. Use callbacks for durable reconciliation and bounded polling for immediate UX if the chosen API supports both. Deduplicate either input by attempt ID plus provider event identity, and make transitions monotonic so a late pending observation cannot overwrite a terminal state.
Roll out the contract before the traffic
First, run the adapter against a fake gateway that produces delayed, duplicated, out-of-order, and permanently unknown observations. Verify that one user action cannot escape resend limits, that suppressed destinations never reach the gateway, and that logs join dispatch to verification without storing the OTP itself. Then validate real routes with controlled US and EU destinations and review the results by region rather than only as a global average.
Release behind a cohort control. Watch accepted-to-terminal time, verification completion, resend count, suppression decisions, and recovery usage as separate measures; no single percentage explains the system. Give support a way to inspect the attempt timeline without revealing the code or unnecessary destination data.
Finally, rehearse replacement. Implement a second fake adapter, switch it through configuration, and confirm that login policy, suppression, UI status, and audit records do not change. That exercise is the clearest test of the architecture: the messaging service should be replaceable, while the authentication decisions remain yours.
References
- Apple, "Use Mail Privacy Protection on iPhone": https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
- MDN, "Fetch API": https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
Top comments (0)