Short answer: treat a missing telehealth login code as a traceable, multi-hop event—not a resend button problem. Keep one event ID from your application through sender registration, carrier filtering, and handset delivery, then make resend attempts obey an OTP policy.
I run a small SaaS, so my useful unit is revenue per hour. A support ticket that says “SMS failed” is expensive because it sends me hunting through five dashboards. The first design decision is therefore boring: every notification gets an immutable event ID, a redacted destination hash, country, carrier route, template version, and attempt number. That record lets a one-person team answer “where did it stop?” without collecting the code itself.
How can event notifications reduce SMS verification failures across carriers?
Start with a state machine. created means the login challenge passed local validation. accepted means the messaging provider accepted the request. delivered means its downstream delivery receipt says the handset network accepted it. expired and blocked are application decisions, not carrier diagnoses. Keep those states separate; a provider timeout is not proof that a carrier filtered a message.
For each transition, store an event ID, UTC timestamp, region, sender identity, route class, and provider response code. Hash the phone number with a keyed hash so repeated attempts can be joined without putting a phone number in logs. Never log the OTP, message body, or an authorization header. Set each code to expire quickly, accept it once, cap attempts, and bind it to the session that issued it.
That is the whole point.
Here is the smallest implementation I can ship in a week. It deliberately has no vendor-specific SDK.
type DeliveryState = "created" | "accepted" | "delivered" | "expired" | "blocked";
type OtpEvent = {
id: string;
state: DeliveryState;
country: "US" | "GB" | "DE" | "FR" | "OTHER";
attempt: number;
createdAt: string;
destinationHash: string;
templateVersion: string;
};
function canResend(previous: OtpEvent | undefined, nowMs: number): boolean {
if (!previous) return true;
const ageMs = nowMs - Date.parse(previous.createdAt);
return ageMs >= 30_000 && ageMs < 10 * 60_000 && previous.attempt < 3;
}
The 30-second floor is a product choice, not a carrier standard. I use a ten-minute challenge window and three attempts here because they are easy to explain and monitor; your threat model may call for tighter limits. I'm not sure those exact values fit every clinic, especially where a patient shares a phone or has accessibility needs. Measure completion and abuse separately before changing them.
How do carrier filtering, sender registration, and signatures interact?
Filtering is usually a combination of traffic pattern, sender identity, content, and recipient behavior. A sudden burst of identical login messages can look unlike normal patient traffic even when every message is legitimate. US long-code application-to-person traffic may require sender registration; in Europe, country rules and sender types differ, and a sender that works in one country can be rewritten or rejected in another. Registration is a prerequisite for some routes, not a guarantee of delivery.
Keep the message recognizable: a clinic name, a plain purpose (“Your sign-in code”), an expiry, and a support path. Avoid URL shorteners and ambiguous copy. A signature is useful when your organization requires one, but adding more text can push a message into another segment or trigger content rules. Version the template so a delivery drop can be correlated with the exact wording rather than guessed from memory.
I separate sender identity from message content in configuration. That makes a route change reversible and leaves an audit trail. It also exposes a hard limit: if a destination country does not support the sender type or registration you have, the right response is to offer an approved alternate channel, not to press resend forever. In practice, the useful debugging story is a single timeline: at 09:14:02 the login service creates event evt_7f2; at 09:14:03 the transport accepts it; at 09:14:18 a receipt marks it filtered for a US route; at 09:14:35 the patient requests a new challenge. The second event should reference the first, invalidate its code, and carry a reason such as carrier_policy, while the dashboard groups both under the same session. That level of detail turns a vague “SMS is broken” report into a bounded decision about sender registration, content, or fallback. It also keeps support from asking a patient to read a secret code aloud, which is a privacy failure regardless of delivery status.
A failure triage loop that does not guess
When a patient reports no code, support follows the event ID in this order:
- Confirm the challenge is still valid and the attempt was not blocked by the application's rate limit.
- Check the provider acceptance response and normalized error class. Authentication, malformed destination, and policy rejection belong to different queues.
- Compare the delivery receipt with the carrier and country.
acceptedwithout a receipt is an observability gap; it is not a successful delivery. - Check sender registration and template version for that route. Do not silently swap identities during a live challenge.
- Offer voice or email only after recording the fallback reason, and invalidate the older code when a new channel wins.
One short rule helps: retry transport, not policy. A network timeout can be retried with an idempotency key. A filtering or registration rejection needs a route or content decision. Replaying it three times only creates duplicate texts and a worse patient experience.
Which trade-offs matter for a one-person SaaS?
| Choice | Helps | Costs or boundary |
|---|---|---|
| One sender per region | Clear compliance ownership | More registration and monitoring work |
| Immediate resend | Feels responsive | Duplicates messages and raises filtering risk |
| Longer code lifetime | Helps delayed delivery | Expands the window for interception |
| Voice/email fallback | Covers unreachable handsets | Adds consent, accessibility, and fraud paths |
| Full message-body logging | Easy debugging | Exposes health and identity data |
The catch is operational load. A self-hosted queue gives control and can be the right fit when you already operate carrier connections, but it is not suitable when one engineer needs to ship clinical features every week. A managed route can reduce integration effort, yet it introduces provider-specific receipt semantics and a new dependency. Pick based on the hours you can spend maintaining registration, templates, and escalation—not on a headline delivery percentage.
I keep a weekly review under fifteen minutes: delivery by country, acceptance-to-receipt delay, resend rate, and verification completion. I look for shape changes, not a magic threshold. Your mileage may vary because handset coverage and local sender rules change; annotate those changes in the same event log so a future comparison has context.
Further reading
References
- OWASP Forgot Password Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- Yahoo Sender Best Practices and Requirements: https://senders.yahooinc.com/best-practices/
- GSMA SMS guidelines overview: https://www.gsma.com/solutions-and-impact/technologies/networks/mobile-identity/sms/
Top comments (0)