TL;DR: SMS OTP delivery can fail when carrier filtering, sender registration, shared routes, or anti-fraud controls reject or delay the message. Own the verification template and its version when the message is part of a legal-intake control. Let a delivery service own transport adaptation, but never let it erase the sender profile, route class, attempt history, or terminal delivery evidence. An accepted API request is not proof that a phone received a code.
| Ownership model | Pick it when | What your team must retain | Main trade-off |
|---|---|---|---|
| Application-owned template | Wording is part of consent, intake, or audit review | Immutable template version, locale, purpose, and approval record | More release discipline; clearest evidence |
| Messaging-layer template | Several applications share the same verification policy | Stable template key and revision in every event | Easier consistency; ownership can become ambiguous |
| Transport-managed template | The transport must adapt tightly to a registered sender program | Mapping from internal intent to the rendered revision | Less rendering control; route changes are easier to absorb |
The useful split is simple: the application owns meaning; the transport layer owns delivery mechanics. For a healthtech legal-intake flow, that means the same communication ledger can follow an identity check and the order receipt emitted after payment settles, while keeping those two purposes distinct.
How can carrier filtering and shared routes make SMS OTP delivery fail?
The code can be valid, unexpired, and attached to the right intake session while the message containing it is filtered. Carrier filtering, sender registration, shared routes, and anti-fraud controls sit after code generation. Treating them as one generic send failed outcome hides the part operators need.
Accepted isn't delivered.
Picture the path in words: intake application -> verification policy -> template renderer -> sender profile -> messaging route -> carrier controls -> handset. A provider may accept work near the middle of that path. That acceptance says nothing conclusive about the last hop. Keep accepted, delivered, undelivered, and unknown separate in your model, and make the evidence timestamped.
This matters across the US and EU because sender identity and route treatment are contextual. Do not infer that a registration or sender choice used for one destination applies to another. US A2P 10DLC documentation, for example, describes registration for application-to-person traffic over US long-code routes. It is evidence for that route class, not a universal delivery guarantee.
Shared routes add another boundary. Multiple traffic sources can affect how a route is evaluated, while your application sees only its own request. Dedicated identity can improve attribution, but it still does not turn delivery into a certainty. The operational answer is better evidence, bounded retries, and a second verification method chosen by policy.
Pick application ownership when wording is evidence
Choose application-owned templates when reviewers must reconstruct exactly what the person was told. Store an immutable revision such as legal-intake-otp@7; do not overwrite revision 7 when the copy changes. Record the purpose separately from the text, because a verification message and a settled-payment receipt have different business meaning even if both go to the same phone.
The cost is coordination. Copy review becomes a deployment concern, locale changes need tests, and sender-registration changes may require a mapping update outside the application. I would accept that friction here. It creates a crisp chain from intake session to approved wording to delivery attempt.
Keep secrets out of that chain. Store a one-way representation of the OTP for verification, and keep the rendered code out of logs. The event needs a template revision and correlation identifier, not message content.
Pick messaging-layer ownership for shared policy
A messaging layer is a strong owner when several intake surfaces must apply the same expiry wording, locale fallback, and sender mapping. The application sends an intent plus typed data; the layer resolves a reviewed revision. This reduces copy drift.
It also creates a common trap: a mutable template key can make yesterday's event impossible to explain. Fix that by returning the resolved revision with the acceptance event. If the transport cannot expose that revision, application ownership is the safer audit boundary.
Transport-managed templates are narrower. Use them where a registered program binds content and sender identity closely enough that local rendering would create mismatch risk. Preserve your internal intent ID and the external template reference. You need both sides of the join.
Implement the evidence trail, not a send wrapper
Start with a small contract. The destination is normalized before this boundary, the OTP itself is never logged, and every transition is idempotent.
type DeliveryState =
| "created"
| "accepted"
| "delivered"
| "undelivered"
| "unknown";
type VerificationIntent = {
intentId: string;
intakeSessionId: string;
destinationRegion: "US" | "EU";
templateRevision: string;
senderProfile: string;
expiresAt: string;
};
type DeliveryEvent = {
intentId: string;
attempt: number;
state: DeliveryState;
occurredAt: string;
routeClass?: string;
reasonCategory?: "filtered" | "unreachable" | "expired" | "other";
};
Do not make the provider response your domain record. Create the intent first, then append transport observations. That ordering lets an operator distinguish "we never attempted" from "the transport accepted it." It also prevents a callback race from inventing an intent after a fast status event arrives. Consider one concrete sequence: attempt 1 is accepted, its status callback is delayed, the user requests another code, and attempt 2 is accepted before the first callback appears. The ledger must attach each observation to its attempt instead of replacing one mutable status field. The verification policy must also decide which challenge remains valid. Otherwise, a late delivered event for attempt 1 can look like proof about attempt 2, even though transport state and challenge state describe different objects. This is the trade-off: append-only evidence takes more storage and query work, but it preserves the chronology that an overwrite destroys.
interface EventStore {
append(event: DeliveryEvent): Promise<void>;
list(intentId: string): Promise<DeliveryEvent[]>;
}
async function recordStatus(
store: EventStore,
event: DeliveryEvent,
): Promise<void> {
const prior = await store.list(event.intentId);
const duplicate = prior.some(
(item) =>
item.attempt === event.attempt &&
item.state === event.state &&
item.occurredAt === event.occurredAt,
);
if (!duplicate) await store.append(event);
}
Production idempotency belongs in a unique storage constraint, not only in this readable example. Use a stable upstream event identifier when one exists; otherwise define a deterministic event key at the adapter boundary. Test duplicates and reordering. They are ordinary webhook conditions, not exceptional ones.
Keep the join boring.
Alert on the funnel. Count created intents, accepted attempts, terminal outcomes, and verifications completed before expiry, grouped by destination region, sender profile, template revision, and route class. Avoid phone numbers and rendered bodies as metric labels. A sudden gap between accepted and terminal outcomes deserves investigation; a gap between delivered and completed verification points toward expiry, user behavior, or the intake experience rather than transport alone.
Retries need policy, too. Never issue several simultaneously valid codes because a callback is slow. A resend should either reuse the active challenge under a clearly defined policy or invalidate the earlier challenge atomically. Cap attempts, apply rate limits by account and destination, and route suspicious patterns into a separate risk decision. Anti-fraud controls protect the login surface, but aggressive controls can also deny a legitimate intake. Measure both outcomes.
For the settled-payment receipt, reuse the event vocabulary and monitoring pipeline, not the OTP retry semantics. A receipt can tolerate a different fallback and retention policy. Conflating the two is how a harmless receipt retry becomes a dangerous verification retry.
Test the boundaries before changing routes
Unit tests should lock template revision selection, locale fallback, expiry behavior, and redaction. Integration tests should replay duplicate and out-of-order status events. In staging, use controlled destinations and synthetic intent IDs so dashboards prove the full correlation path without placing real legal-intake data in fixtures.
During deployment, compare the funnel by template revision and sender profile. Roll back a rendering change independently from a route configuration change. That separation turns two possible causes into two observable changes. Fast diagnosis follows.
Three signals are especially useful: terminal-outcome lag, the share of intents still unknown near expiry, and verification completion after a delivered event. None proves why one carrier filtered one message. Together they tell the on-call engineer where to inspect next without exposing the code or message body.
Limits and decision rule
Delivery evidence is not handset proof, and sender registration is not an exemption from filtering. A second channel also changes the threat model; choose it through security and legal review, not as an automatic escape hatch.
Own templates in the application when wording itself is evidence. Centralize them when consistent policy across several applications matters more, provided every event retains the resolved revision. Delegate rendering only when transport constraints require it, and preserve a durable mapping back to your intent. In every model, observe the whole path and keep verification state separate from message state.
Top comments (0)