A payment-settled event creates two very different obligations in a media business: send the order receipt, and, when a legal intake flow needs it, verify that the claimant controls a phone number. SMS OTP delivery can fail after submission because carrier filtering, sender registration, shared routes, or anti-fraud controls sit beyond the application. TL;DR: accept an OTP only after the user proves receipt, keep the receipt on an independently observable email path, and record every SMS state transition without recording the code. A provider accepting an SMS is evidence of submission, not evidence that a handset displayed it.
The integration with the fewest SDK calls is not necessarily the integration with the least work. The useful boundary is a small message state machine, because carrier filtering, sender-registration requirements, shared-route reputation, and anti-fraud controls can all intervene after an application gets a successful API response.
Why can SMS OTP delivery fail after carrier filtering?
There are several decision makers between an application and a handset. The messaging service can accept a request, an upstream route can select a sender, and a carrier can filter the traffic. In the United States, application-to-person traffic sent over 10-digit long codes is subject to A2P 10DLC registration; the registration model covers brands and campaigns, not just possession of a phone number. The cited compliance documentation also distinguishes registration from later message handling.
The EU is not one uniform delivery domain. Sender identity and filtering constraints vary by destination, so a sender that works for one country cannot be assumed to work unchanged everywhere else. That is an integration constraint, not a reason to guess at carrier behavior. Store destination country, sender class, and route class with the attempt, then test the countries the intake flow actually serves.
Shared routes add another dependency: reputation is not fully controlled by one application. Anti-fraud systems may also react to bursts, repeated requests, suspicious destinations, or message patterns. The application rarely receives a neat explanation suitable for showing to a user. Do not translate an ambiguous delivery state into "wrong phone number."
One distinction prevents a lot of false confidence:
-
acceptedmeans the messaging edge took the request. -
deliveredmeans a downstream delivery signal arrived, where that signal is available. -
verifiedmeans the user returned the correct, live challenge within policy. -
expired,superseded, andlockedare application decisions.
Only the third state completes phone verification.
Model the workflow, not the messaging SDK
A compact domain interface keeps provider response shapes out of the legal intake logic. It also makes fallback policy explicit. The order receipt can be queued after payment settles and delivered by email; the OTP remains a short-lived challenge tied to one intake session. They may share tracing infrastructure, but they should not share a success flag.
type AttemptState =
| "created"
| "accepted"
| "delivered"
| "failed"
| "expired"
| "superseded";
interface OtpAttempt {
id: string;
intakeId: string;
destinationCountry: string;
senderClass: "registered-long-code" | "short-code" | "alphanumeric" | "other";
routeClass: "dedicated" | "shared" | "unknown";
state: AttemptState;
createdAt: string;
expiresAt: string;
}
interface SmsTransport {
submit(input: {
attemptId: string;
destination: string;
body: string;
}): Promise<{ externalId: string; acceptedAt: string }>;
}
Do not persist the plaintext OTP in that record. Persist a verifier suitable for the challenge design, enforce expiry and attempt limits in the application, and make a resend supersede the earlier challenge. Otherwise, two valid codes can race and produce a support case that looks like carrier filtering.
The callback consumer needs the same discipline. Authenticate callbacks using the mechanism specified by the transport, reject malformed events, deduplicate by event identity, and permit only valid transitions. Delivery events can arrive late or out of order. A late delivered event is useful telemetry, but it must not revive an expired challenge.
The focused experiment
Start with one paid media order that opens a legal-rights intake after settlement. Emit a stable correlation ID at payment settlement. The receipt worker uses it when submitting the email, while the verification worker creates a separate OTP attempt linked to the intake ID. Never put the OTP, full phone number, or sensitive intake answers into logs.
The failed-simple design marks the intake as "SMS sent" when submission returns successfully and starts a generic retry timer. That collapses transport acceptance, carrier disposition, and human proof into one Boolean. It also encourages retries that may resemble abuse and makes the receipt look failed when only the OTP path is uncertain.
The chosen experiment records timestamps for creation, acceptance, delivery update, successful verification, expiry, and resend. Segment results by destination country, sender class, route class, and deployment version. Keep the sample honest: a delivery callback and a completed challenge answer different questions, so report them separately.
A useful test matrix is small enough to run before launch:
| Condition | Expected application behavior | Signal to inspect |
|---|---|---|
| Transport rejects submission | Keep challenge unverified; offer a bounded retry | Rejection category and latency |
| No delivery update arrives | Keep state unknown until expiry | Verification completion, not callback absence alone |
| User requests another code | Supersede the old challenge | Resend count and time between attempts |
| Delivery update arrives after expiry | Retain telemetry; do not revive code | Out-of-order transition count |
| Receipt email is delayed | Retry receipt independently | Email acceptance and delivery telemetry |
This is deliberately narrower than a global launch test. It checks whether the application tells the truth about state before the team adds more countries, senders, and routes.
Integration effort belongs in operations
Count integration effort beyond the first successful request. Sender registration, country-specific configuration, callback authentication, idempotency, redaction, retention, reconciliation, and support tooling are part of the implementation. A thin API can still produce a thick operational burden.
The trade-off is real. This state-machine approach is not suitable as a shortcut for teams unwilling to own callback processing and reconciliation; it adds storage, transition rules, and on-call questions. A simpler submission-only integration takes less initial work, but its limitation is that it cannot distinguish transport acceptance from verification. That costs time later.
Cost matters, but per-message price is a weak primary metric here. A cheaper path that provides ambiguous states can increase retries and manual intake support. Track attempts per verified session, time to verification, expiry rate, resend rate, submission rejection rate, and the share of attempts that remain in an unknown delivery state. Compare those measures by route attributes rather than publishing one blended "delivery rate."
Keep the user-facing recovery path plain. Allow a bounded resend, explain that delivery may take time, preserve the intake session, and offer a different verification channel when policy permits. Do not claim that email is equivalent proof merely because the receipt already uses email; the assurance requirement determines the acceptable fallback.
Before copying this design, measure where uncertainty enters your own route. If most failures happen before acceptance, inspect request construction and registration mapping. If acceptance is high but verification completion drops for a destination segment, investigate sender and route eligibility with the relevant transport and carrier evidence. If resends spike while delivery remains healthy, inspect latency, user interface timing, and code-supersession behavior.
Top comments (0)