Short answer: choose an SMS provider only after your FastAPI application owns the compliance evidence, suppression decisions, and status normalization for each signup verification link; keep later warehouse pickup codes in a separate purpose-bound flow. The cheapest beginner stack is the one your team can audit without reconstructing intent from provider logs.
For an e-commerce account used at warehouse pickup, the awkward constraint is evidence. A message being accepted by an API doesn't prove that the shopper was eligible to receive it, that the address was checked against the current suppression state, or that the link was used for its declared purpose. Those are application facts. Put them in your database before evaluating transport.
This also changes the 2FA question. A signup verification link proves control of a destination during enrollment; a pickup code authorizes one transaction at one location. Reusing one token, template, or retention rule for both makes incident review muddy. Don't.
What evidence should an e-commerce signup preserve?
Start with an append-only attempt record. It should answer who requested the action, which policy was evaluated, what purpose was declared, and how the transport state changed. Store a digest of the token rather than the token itself, and keep the destination out of free-form logs. The record needs stable internal identifiers so an operator can follow one attempt without searching by a phone number or email address.
A useful model has three clocks: requested, accepted by the transport, and verified by the user. They are different events. Expiration belongs to the credential, while delivery status belongs to the message attempt. A late delivery must never extend the credential lifetime. This distinction is easy to miss in a beginner implementation because the first demo has one row and one boolean named verified; under retries, callbacks, and manual suppression, that boolean stops explaining what happened.
Use reason codes that describe your own policy, such as suppressed_recipient, expired_credential, or attempt_limit_reached. Preserve the transport's raw status in a restricted payload if your retention policy permits it, but drive business logic from a small internal state vocabulary. That keeps a provider-specific label from silently changing whether a shopper can retry.
Email introduces another evidence trap. Apple's Mail Privacy Protection can prevent senders from learning whether a recipient opened a message, so an open event is weak evidence for account verification. The verification-link redemption recorded by your application is the meaningful event. SMS status has the same architectural lesson: transport telemetry can explain delivery, but it shouldn't stand in for proof that the user completed the action.
The audit unit is one purpose-bound attempt, not one phone number.
How should a beginner poll SMS OTP status for warehouse pickup codes?
Poll your own status resource, not the provider directly from the browser. The backend maps provider states into a compact lifecycle such as queued, sent, delivered, failed, and unknown; the UI receives only the fields it needs. MDN documents the browser Fetch API used for this kind of request, but the important design decision sits behind it: authentication, authorization, and transport credentials remain server-side.
Polling is a read path. It must not resend a message, rotate a credential, or clear a suppression decision. Return a stable attempt identifier and a monotonic application state where possible. Provider callbacks and scheduled reconciliation may race, so state updates need an ordering rule based on recorded event time plus a deterministic precedence rule. delivered arriving after failed, for example, should be evaluated as an event transition rather than whichever database write happened last.
Consider a design exercise with attempt signup_2048. The application records queued at 10:00:00 and gives the browser that internal ID. A reconciliation worker observes sent at 10:00:07, but its database write stalls behind another transaction. Meanwhile, a callback carrying delivered with an observation time of 10:00:09 commits first. When the worker resumes, a last-write-wins update would move the record backward from delivered to sent, inviting the UI to keep polling and an operator to misread the timeline. The reducer below retains delivered because the delayed event is older. Now change the exercise: the verification link expired at 10:00:08. The message can still be truthfully recorded as delivered at 10:00:09, while redemption must be denied because credential expiry is a separate clock. Nothing needs to rewrite delivery as failure. That split preserves both facts, which is exactly what a compliance review needs when transport timing and application authorization disagree.
No resend.
The following reducer is deliberately transport-neutral. It rejects an event older than the last accepted event and permits only declared transitions. An unrecognized external state maps to unknown; it doesn't invent success.
from dataclasses import dataclass
from datetime import datetime
ALLOWED = {
"queued": {"sent", "delivered", "failed", "unknown"},
"sent": {"delivered", "failed", "unknown"},
"unknown": {"sent", "delivered", "failed"},
"delivered": set(),
"failed": set(),
}
@dataclass(frozen=True)
class DeliveryState:
value: str
observed_at: datetime
def apply_delivery_event(
current: DeliveryState, incoming: DeliveryState
) -> DeliveryState:
if incoming.observed_at < current.observed_at:
return current
if incoming.value not in ALLOWED.get(current.value, set()):
return current
return incoming
Keep the shopper-facing response calmer than the operator view. We couldn't confirm delivery is usually enough for the UI, while the evidence record retains a precise internal reason and correlation ID. Don't leak whether a particular destination is suppressed during unauthenticated signup; that can turn a helpful diagnostic into an account-discovery signal.
Five seconds is a plausible interface interval in a prototype, but it isn't a universal recommendation. Your mileage may vary with transport behavior, user patience, and provider limits, none of which the supplied public sources quantify. Measure the request volume and stop polling when the credential expires, the attempt reaches a terminal state, or the page closes.
Suppression belongs before message creation
Suppression is not merely a provider feature. The application must decide whether the destination, purpose, region, and current consent state permit a send before it creates a transport request. That decision should be atomic with recording the attempt; otherwise two concurrent signup requests can both pass the check.
Keep global blocks separate from purpose-specific choices. A hard operational block may prevent every message, while a marketing preference shouldn't automatically disable a requested account-security message. The exact categories depend on the policy approved for your service, and I'm not sure a generic taxonomy can settle that for every US and EU deployment. Legal and compliance owners must define the categories, retention periods, and evidence fields; engineering should make those rules explicit, versioned, and testable.
One compact decision function is easier to inspect than conditions scattered across request handlers:
from dataclasses import dataclass
@dataclass(frozen=True)
class SendDecision:
allowed: bool
reason: str
policy_version: str
def decide_signup_send(
globally_blocked: bool,
purpose_blocked: bool,
attempts_in_window: int,
attempt_limit: int,
) -> SendDecision:
version = "signup-verification-v3"
if globally_blocked:
return SendDecision(False, "global_suppression", version)
if purpose_blocked:
return SendDecision(False, "purpose_suppression", version)
if attempts_in_window >= attempt_limit:
return SendDecision(False, "attempt_limit_reached", version)
return SendDecision(True, "policy_allowed", version)
Record the returned reason even when sending is denied. Silence without evidence is painful during support review: the shopper reports no link, the dashboard shows no transport request, and nobody can distinguish a deliberate suppression from a lost application branch. A denied decision is still an event.
Denial is evidence.
Then separate pickup authorization. A warehouse code should carry its own purpose, expiry, redemption state, order reference, and attempt counter. It should not inherit signup consent merely because the same destination appears on both records. This is the edge case worth designing first — one customer, one destination, two credentials, two policy decisions.
Compare stacks by control boundaries, not message price
A practical shortlist begins with four boundaries: where suppression is decided, who owns the evidence ledger, how delivery states are normalized, and how credentials are rotated or revoked. Compare total operational cost only after those answers are clear. A low per-message figure can be irrelevant if routine investigations require manual log joins or if changing transports rewrites authentication code.
| Decision area | Application-owned boundary | Transport-owned boundary | Test before launch |
|---|---|---|---|
| Eligibility | Purpose, policy version, suppression result | Destination acceptance | Concurrent duplicate requests |
| Credential | Digest, expiry, attempt count, redemption | Message rendering and delivery | Late delivery after expiry |
| Status | Internal lifecycle and operator reason | Raw delivery events | Duplicate and out-of-order events |
| Evidence | Correlation IDs and retention policy | Provider event payload | Export one attempt without destination search |
For a small team, a managed SMS API can reduce transport operations, while an application database remains the clean place for policy evidence. A self-hosted transport can offer more infrastructure control, but it also moves delivery operations and abuse handling onto the team. An email verification link may be appropriate where the user can access email during signup; it is not a drop-in substitute when the warehouse workflow genuinely requires a phone-bound pickup credential.
The catch is that status polling adds read traffic and still cannot prove user intent. Prefer a callback-driven backend with bounded reconciliation when the transport supports dependable event delivery, then let the browser poll your normalized record only while the user is waiting. Stick with a simpler synchronous acknowledgement when delivery status has no effect on the user journey and your evidence requirement ends at transport acceptance.
No single stack wins across compliance evidence, operational ownership, and user access. Write a weighted decision sheet before the trial: audit export gets the highest weight here, followed by suppression semantics, late-event handling, regional availability, and operating cost. Reject any candidate that forces the browser to hold transport credentials or makes a resend indistinguishable from a status read.
Roll out with shadow evidence first
Migration should be boring. Add the internal attempt ID, policy version, purpose, and normalized state before changing delivery transport. In the first phase, keep the existing send path and write the new evidence record in shadow mode; compare record completeness, not delivery claims. Next, route a small internal test cohort through the new adapter, exercise suppression and expired-link cases, and verify that operators can reconstruct an attempt from the application record alone.
Then widen traffic gradually while keeping the old adapter available for rollback. A transport switch must not reactivate suppressed destinations, reset attempt counters, or change credential expiry. Test those invariants at the adapter contract, and test the polling response separately from provider event ingestion.
Ship only when a reviewer can answer three questions from one record: why the send was allowed, what happened to the message, and whether the verification link was redeemed before expiry. Pickup codes get the same evidence shape but a different purpose and policy version.
Small boundary. Clear proof.
Top comments (0)