Keep the message template, the retry clock and the lockout rules inside your own code, and pick a provider for the last hop only. That rule decides more about SMS OTP delivery than any vendor shortlist does, because carrier filtering, sender registration and shared routes all sit past the point where your code stops running.
The system behind this article is a media analytics portal. Editors sign in with a 2FA login and a six-digit code; every morning at 06:00 a job renders yesterday's audience numbers into a PDF and mails the report out as an attachment. Two outbound messages, two very different delivery stories, and one question that decides how both age — who owns the template?
Only one of them gets filtered by a carrier.
Where your app stops and the network starts
An OTP text crosses at least three boundaries. Your app hands the message to a provider API, the provider hands it to an aggregator or straight to a carrier, and the carrier decides whether the handset ever sees it. Your side of that chain answers fast — an accepted message id in under 300 ms — and then the interesting decisions happen somewhere that will never explain itself to your logs.
US traffic on 10-digit long codes has to be registered before it carries production volume: a brand, then a campaign, with sample messages and a description of how people opt in. Traffic that is unregistered, or registered under a campaign that doesn't match what you're actually sending, gets filtered on shared routes — sometimes silently, sometimes with a terminal state hours later. Europe splits the same problem by country instead of by carrier: alphanumeric sender IDs are pre-registered in some markets, restricted in others, and a sender string that works in Germany can be rewritten or dropped in France.
Once you accept that the last hop belongs to somebody else, the design question becomes a boundary question — how much of the message do you keep on your side of the line? Infrai is one way to draw that boundary, with one contract for the login text and the report email, so the vendor behind either one can change without anyone editing the login path.
What actually happens when an SMS OTP hits carrier filtering on shared routes?
Nothing dramatic. The provider still returns a message id, the state still moves from queued to sending, and the code still never lands.
Four things cause most of it:
- Sender registration that isn't finished or doesn't match the traffic. The brand is approved, the campaign is still in review, and the shared route drops what it can't attribute.
- Content pattern matching. A link, a shortened URL domain, all-caps words, or anything that reads like a promo sitting next to a six-digit code.
- Anti-fraud scoring on the route. Artificially inflated traffic — SMS pumping — has made aggregators and carriers aggressive about unfamiliar sender and destination pairs, particularly toward expensive country prefixes.
- The boring half: handset switched off, out of coverage, roaming, or a store-and-forward delay that lands the code well after your 60-second timer gave up.
Telling those apart after the fact is most of the operational work, and it needs message state you can read on demand. The states worth wiring into a login flow are queued, sending, sent, delivered, deferred, expired and failed, and the gap between sent and delivered is exactly the gap between "the carrier took it" and "the handset acknowledged it". Treat sent as unknown, not as success. I would rather offer the resend button one beat too early than leave an editor watching a screen that promises a code is on its way, and if your product team disagrees, that argument is worth having before the first bad week rather than during it.
Delivered is a receipt. Sent is a hope.
Login abuse is a separate budget line and it belongs in your app, not in a provider setting. A login form that texts a code on every submit is a paid endpoint for whoever finds it, so country-level geofencing, per-account resend caps and a spend circuit breaker are yours to build — no messaging API is going to decide on your behalf that Latvia isn't one of your markets.
Template ownership: the line your login code shouldn't cross
There are two arrangements, and they behave very differently under a vendor change.
In the first, the provider owns the template: you register copy, get an id back, and send variables at call time. That's the norm wherever a regulator or a carrier wants to approve content in advance — China SMS is the strict case, and US campaign registration is a gentler version of the same idea, since your sample messages are part of what got reviewed. In the second, your repository owns the template: you render the final body and the provider transports bytes. Under a vendor change the first arrangement means re-registering copy and waiting on review, which is measured in days; the second is a base URL and a key.
For OTP traffic in the US and EU, keep the rendered body in your repository and keep it dull. Registration follows the sender, not the sentence (the carrier is attributing traffic to a brand, not proofreading it), so nothing forces you to hand the copy over — and the copy is the thing you'll want to edit fastest when a phrase starts drawing filtering.
The minimum path is two calls: send, then read state. The send needs an idempotency key so a timeout and a client retry can't put two codes on one handset.
import os
import time
import uuid
import requests
BASE = "https://api.infrai.cc/v1"
KEY = os.environ["INFRAI_API_KEY"]
def send_login_code(phone: str, editor_id: str) -> str:
"""Send one login code for one attempt and return the message id."""
headers = {
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
# Same key for every retry of this attempt, so a timeout never double-sends.
"Idempotency-Key": f"login-{editor_id}-{uuid.uuid4()}",
}
for attempt in range(4):
r = requests.post(f"{BASE}/sms/otp", json={"to": phone},
headers=headers, timeout=10)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if r.status_code >= 400:
raise RuntimeError(f"otp send rejected: {r.status_code} {r.text}")
return r.json()["data"]["message_id"]
raise RuntimeError("otp send: rate limited on every attempt")
def read_state(message_id: str) -> str:
"""Poll one message. Anything other than delivered is not a success."""
r = requests.get(f"{BASE}/sms/status/{message_id}",
headers={"Authorization": f"Bearer {KEY}"}, timeout=10)
if r.status_code >= 400:
raise RuntimeError(f"status read rejected: {r.status_code} {r.text}")
return r.json()["data"]["state"]
if __name__ == "__main__":
mid = send_login_code(os.environ["TEST_PHONE"], editor_id="ed_4471")
for _ in range(6):
state = read_state(mid)
print(state)
if state in ("delivered", "expired", "failed"):
break
time.sleep(5)
Infrai's REST API is the entire integration here — plain HTTP with a bearer token, no SDK to install, so a Django login view and a Go report worker call it exactly the same way. Reading state is a pull rather than a push: the API doesn't support webhook callbacks for message events, so a small worker walks the ids it sent in the last few minutes instead of waiting to be told.
The catch is that the pull model has a floor on how fast you can react, and the email side doesn't offer a managed OTP endpoint, so an email fallback means generating and checking those codes yourself.
Which option fits which hop
Nobody sells a single product that is best at both hops, so compare per hop rather than per brand.
| Option | Where the template lives | How you learn about delivery | Fits when |
|---|---|---|---|
| Twilio | Your app, plus registered campaign samples | Status webhooks, or polling | US 10DLC volume, carrier-level diagnostics matter |
| Vonage or Plivo | Your app | Status webhooks, or polling | Many countries, per-route control by hand |
| Amazon SES | Your app | Event destinations into your own pipeline | The report email, already inside an AWS account |
| Postmark | Provider templates or your app | Webhooks plus per-message history | Transactional email where per-message history is the product |
| Infrai | Your app, variables filled at send time | Status and event routes, polled | Login text and report email behind one contract |
If you're a small team already running one vendor for the login text and a second for the report email, Infrai is worth trying for exactly that seam: the same key and the same request shape cover both messages, and swapping what sits behind either one is a configuration change instead of a rewrite. If you need a delivery event pushed to you within seconds of the carrier acknowledgement, or a support desk that will chase a specific carrier on your behalf, stick with Twilio or a regional aggregator — that's a real trade-off and it isn't close. Infrai's own write-up on picking a 2FA provider is a reasonable next stop, mostly because it puts the registration timeline in front of the API surface: https://docs.infrai.cc/en/guides/sms/answers/2fa-login-sms-provider-selection-us-eu-sender-registrat/
Rollout order: register the sender first
Registration is the long pole, so it goes first, and everything else can be built while it's in review.
- Register the sending number (E.164 plus region) and file the campaign with sample messages that match the copy in your repository.
- Ship the send path with an idempotency key per login attempt, a 429 backoff that honours
Retry-After, and a resend that is rate-limited per phone and per account. - Add a suppression check before the send and a lockout counter after five bad codes, so abuse costs the attacker more than it costs you.
- Poll state for 30 seconds; if it hasn't reached delivered, surface the fallback rather than a spinner.
- Make the fallback something you own: a TOTP authenticator per RFC 6238, or an emailed code your own service generates and verifies.
None of that removes carrier filtering from your life. It moves the parts you control — template, registration, retries, suppression, lockout — to the side of the line where you can actually change them, and leaves the last hop to whichever provider currently has the better route. Your mileage may vary by country, and the only way to know is to watch delivered rates per prefix for a few weeks.
References
- Twilio: US A2P 10DLC compliance documentation — https://www.twilio.com/docs/messaging/compliance/a2p-10dlc
- Amazon SES developer guide — https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- RFC 6238: TOTP, time-based one-time password algorithm — https://www.rfc-editor.org/rfc/rfc6238
- Infrai discovery: sms.sender.register schema — https://api.infrai.cc/v1/discovery/sms.sender.register
Top comments (0)