An SMS login flow looks simple: generate a code, send it, and verify the response. Roaming turns that operation into a chain of independent systems.
Your application may accept the request. Your messaging provider may accept the message. A carrier may route it toward the subscriber's home network. A visited network may register the handset. The phone may be connected but using the wrong SIM for messages. The destination service may also reject a number range or pause an account before any message is sent.
That is why “the code did not arrive” is not a diagnosis. It is only a symptom.
The goal is to make every stage observable, keep retries safe, and give users a useful next action when the route is uncertain.
Model delivery as a state machine
Do not store one boolean called smsSent. “Sent” often means only that your provider accepted an API request.
A more useful model separates application, provider, delivery, and verification state:
type LoginCodeState =
| "created"
| "provider_accepted"
| "provider_rejected"
| "delivery_reported"
| "delivery_unknown"
| "expired"
| "verified"
| "rate_limited";
type FailureClass =
| "invalid_number"
| "provider_configuration"
| "carrier_or_route"
| "account_policy"
| "user_input"
| "unknown";
interface LoginCodeAttempt {
id: string;
accountId: string;
phoneRef: string;
countryCallingCode: string;
providerMessageId?: string;
state: LoginCodeState;
failureClass?: FailureClass;
createdAt: string;
expiresAt: string;
verifiedAt?: string;
}
Make phoneRef a controlled token or an HMAC produced with a server-held key, not a plain hash of an enumerable phone-number space.
The request path should create an attempt before calling the provider, attach an idempotency key, and update the attempt with the provider's response. A later delivery receipt can move it again, but lack of a receipt must remain delivery_unknown, not silently become “delivered.”
Also remember that delivery receipts are not uniformly available or equally authoritative across routes. Your UI should not claim more certainty than your telemetry provides.
Normalize the number once
Store and send phone numbers in E.164 format. Keep country selection separate from the national number in the UI, then normalize on the server.
For example, a UK mobile number written domestically with a leading 0 becomes +44 followed by the remaining digits. If the user selects +44, do not let them submit another 44 inside the national-number field.
Validate syntax, numbering-plan plausibility, and current service policy separately. Policy, provider coverage, fraud controls, and routing can change even when the number remains valid.
Build a user-facing diagnostic path
Use this runbook in order.
1. Confirm whether a request was created
Return a correlation ID to the client and show a neutral state such as “Request received.” If validation, rate limiting, or the provider call failed, record that explicitly.
Keep the public response generic enough to avoid account enumeration. Detailed failure data belongs in authenticated support tooling, not in a message that reveals whether a phone number has an account.
2. Check normalized input
Display a masked destination and country code before sending. Let the user correct the number instead of repeatedly requesting codes to the wrong destination.
Common errors include a duplicated country code, a retained domestic trunk prefix, whitespace copied from a contact, or the number for the wrong SIM on a dual-SIM phone.
3. Read the provider result accurately
Separate immediate rejection from provider acceptance. Capture the provider message ID, timestamps in UTC, error category, and any delivery status that arrives later.
An immediate rejection usually points to input, credentials, sender configuration, unsupported destinations, or provider policy. An accepted request with no delivery confirmation requires a different investigation; it may be a carrier route, filtering, roaming registration, or simply missing receipt data.
4. Test an ordinary SMS
Ask the user to receive one normal text from a trusted contact. This is the most useful branch in the whole decision tree.
- If ordinary SMS also fails, investigate the SIM, account state, handset, signal, and roaming registration first.
- If ordinary SMS works but every application-to-person code fails, investigate the messaging route, sender identity, and carrier filtering.
- If ordinary SMS works and only one service fails, the likely boundary is that service's number support, sending route, cooldown, or account policy.
Ordinary SMS success proves that part of the mobile path works. It does not guarantee that a particular verification sender will use the same path or accept the same number range.
5. Check the roaming environment
For a roaming user, collect a small, non-sensitive checklist:
- Is the relevant SIM line enabled?
- Is the handset registered on a visited network?
- Is automatic network selection enabled?
- On a dual-SIM device, is the expected line active for incoming messages?
- Did a restart or flight-mode toggle refresh registration?
- Can the user receive messages after moving to an area with stronger signal?
Mobile data and SMS are not the same test. A correctly registered line may receive SMS while mobile data is disabled.
6. Stop the resend storm
Repeated clicks create ambiguity. They can trigger rate limits, invalidate older codes depending on the implementation, cause messages to arrive out of order, and make the user try the wrong value.
Allow one active code per account and destination. Apply idempotency for accidental double clicks, show a visible cooldown, and invalidate previous codes when a new one is issued. After a bounded number of attempts, stop sending and route the user to support or another authentication factor.
7. Escalate with evidence, not secrets
A useful escalation includes the correlation ID, UTC timestamps, masked number, country code, provider message ID, state transitions, device type, and the result of the ordinary-SMS test.
Never include the code itself, full phone number, passwords, recovery codes, or message content in routine logs. Use access controls and short retention for the remaining diagnostic data.
Design the fallback before the failure
SMS should not be the only recovery path. Offer passkeys, authenticator apps, or recovery codes; use verified email only for lower-assurance recovery where policy permits.
Fallback does not mean weakening the same control after repeated failures. Support agents should not forward codes, disclose account state, or manually override risk checks because a user says they are abroad. A legitimate fallback should be designed, audited, and protected before the incident occurs.
Set honest UI expectations:
- “Request accepted” is better than “Code sent” when delivery is unknown.
- Show the code lifetime and resend cooldown.
- Explain the expected international number format.
- Provide a short diagnostic link before the user opens a ticket.
Test the failure matrix
Maintain test-owned numbers and cover at least these cases:
| Scenario | Expected system behavior |
|---|---|
| Invalid international format | Reject before provider call |
| Duplicate click | Reuse the idempotent attempt |
| Provider rejects request | Record a classified failure |
| Provider accepts, no receipt | Keep delivery as unknown |
| Code arrives after expiry | Reject it; record it under the verification-attempt policy without consuming a new send |
| User requests a new code | Invalidate the previous code |
| Roaming SIM receives ordinary SMS but not OTP | Escalate sender or route evidence |
| Attempt limit reached | Stop sends and offer a legitimate fallback |
Run the matrix across the countries and carriers you actually support. Do not infer global compatibility from one successful handset.
Disclosure and operating context
Disclosure: I help operate LarkSim, an independent seller and guide for physical giffgaff SIM cards. The ordinary-SMS-first branch in this article grew out of support work documented in this roaming SMS verification troubleshooting guide. That operational experience is not a claim that giffgaff, or any other carrier or number range, will receive codes from every platform.
The broader lesson is provider-independent: authentication teams own the application state and telemetry, carriers own parts of the route, and destination platforms own their acceptance and risk rules. A trustworthy login flow makes those boundaries visible.
Design for uncertainty. Record what happened. Give users one safe next step. Never turn a successful test into a delivery guarantee.
Top comments (0)