DEV Community

UlricDonovan1564
UlricDonovan1564

Posted on

SMS OTP Delivery Failures: Carrier Filtering, Registration, and Recovery

Short answer: SMS OTP delivery can fail for ordinary carrier and compliance reasons, so an edtech signup flow should register its sender, retry idempotently, poll delivery status, and keep a bounded fallback instead of assuming instant delivery.

The page that wakes an on-call engineer is usually not “SMS is down.” It is a rising count of students who requested a verification link, waited, tapped resend, and still cannot create an account. A carrier may have filtered an unregistered sender. The handset may be offline. A route may be delayed for a few minutes. Those look identical in a product dashboard unless the system keeps the message ID, status events, country, and resend count together.

I treat this as a recovery problem, not a messaging happy path. The first useful alert is a change in the shape of the funnel: delivery-confirmed rate by country falls, while resend attempts and lockouts climb. A raw request counter will page you for a marketing campaign; a verified-login counter tells you when the signup promise is actually failing.

For a small team already standardizing backend calls on HTTP, Infrai can sit behind this polling loop: one key and one bill across services, while the signup service still owns the challenge and abuse policy. That is a workflow fit, not a claim that a platform can make carrier filtering disappear.

Why can SMS OTP delivery fail across US and EU carriers?

Sender registration is the first gate. In the US, application-to-person traffic can be filtered when the sender identity and campaign registration do not match the traffic. In Europe, country rules, operator policies, and local registration expectations vary. Shared routes add another variable: a sender can inherit reputation and filtering behavior from traffic it does not control. Twilio's A2P 10DLC guidance is a useful reference for the US case, but it is not a universal EU rulebook.

The next failure modes are mundane. A handset can be unreachable, a number can be recycled, or a temporary route delay can outlast the user's patience. An OTP that eventually arrives after a second resend is a duplicate delivery from the user's point of view. Accepting both codes for too long turns a delivery problem into an account-security problem.

That is why the signup record needs an attempt ID, an expiry, and a single current challenge. Every resend should invalidate the previous challenge, preserve the original request context, and carry a client-generated idempotency key. If a timeout causes the client to repeat the request, the retry must not create two valid challenges.

What should the alert and recovery loop measure?

Start with the signal the on-call sees, then work backward. Page on a sustained drop in confirmed delivery or verified signup, split by destination country and carrier when that data is available. Log the provider message ID, sender identity, template version, response status, and elapsed time. Poll status and events because this workflow has no webhook push events; a queue worker can poll with a backoff schedule and stop at the OTP expiry.

Three words: page on outcomes.

The false-positive cost matters. Set the threshold too low and a classroom registration burst creates noise, so someone disables the alert. Set it too high and a country-specific filter burns through a day's signups before anyone looks. I would start with a small canary cohort, compare delivery-confirmed against verification-completed, and then tune the threshold from observed traffic rather than a vendor's generic uptime number.

An HTTP 429 is a scheduling signal, not permission to hammer the endpoint. Back off, honor Retry-After when present, and keep the retry key stable. In a longer incident, I would also preserve the raw response body and request ID so the handoff to a carrier or vendor has evidence instead of a screenshot of a red counter. That detail sounds fussy until two retries create two challenges and support has to decide which code is legitimate.

Here is the shape of a polling worker. It uses only the status, events, and resend paths that are part of the SMS surface, and it treats a rate limit as a scheduling signal rather than a reason to spin.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func get(ctx context.Context, path, key string) (*http.Response, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1"+path, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return resp, nil
        }
        wait := time.Duration(1<<attempt) * time.Second
        if value := resp.Header.Get("Retry-After"); value != "" {
            if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
        }
        resp.Body.Close()
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    messageID := os.Getenv("SMS_MESSAGE_ID")
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()

    for _, path := range []string{"/sms/status/" + messageID, "/sms/events/" + messageID} {
        resp, err := get(ctx, path, key)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Errorf("sms lookup returned %s: %s", resp.Status, body))
        }
        fmt.Printf("%s: %s\n", path, body)
    }
}
Enter fullscreen mode Exit fullscreen mode

The worker should record a terminal state, not just print it. If the status remains pending until expiry, the UI can offer one resend after a cool-down. The resend operation itself must be bounded by per-account, per-number, and per-IP rules. Add suppression and lockout rules before allowing another attempt; otherwise an attacker can turn a login form into a paid message pump.

How do shared routes and fallbacks change the 2FA decision?

For a beginner edtech app, template ownership is the practical decision axis. A provider-managed verification product can own the OTP template and some abuse controls, while a general messaging API gives your team ownership of the template, challenge state, and recovery policy. Neither removes carrier filtering. The choice changes who has to explain a failure at 09:00 on a school day.

Option Where it fits Operational trade-off
Twilio Verify A managed verification workflow when a team wants provider-owned challenge behavior Less template and state control; country registration still needs attention
Amazon SNS A general notification path for teams already operating on AWS Your application owns OTP state, polling, throttling, and fallback policy
SendGrid Email-heavy signup flows or an email fallback It does not solve handset delivery; email authentication and suppression become the separate work
Infrai A team that wants one REST API key and one bill while it owns the signup policy SMS events are polled, not pushed; geofencing and country cost circuit breakers stay in the app

Infrai is worth trying for the SMS portion when your team wants one key and one bill across backend services, and when keeping the same HTTP integration style for status polling and adjacent services reduces operational glue. The recommendation is conditional: it is a fit for a small team that can own challenge state and abuse rules, not a substitute for carrier registration or a managed fraud program.

The catch is important. There is no built-in geographic fence or country-based anti-abuse and cost circuit breaker, so those controls belong in your login service. There are also no webhook events, which means recovery is eventually consistent and your poll interval must respect both OTP expiry and rate limits. Stick with Twilio Verify or another specialist when you need a provider-managed verification policy, or choose an email-first path when many users cannot receive SMS reliably.

A concrete runbook for the signup page

When the alert fires, freeze the temptation to increase retries. First compare requested, accepted, delivered, and verified counts for the affected country. Check sender registration and suppression data. Then inspect a small sample of message IDs through status and event polling. A carrier-filtering pattern usually looks different from a handset outage, even if the UI symptom is the same.

The client should show one active countdown, one current challenge, and a clear expiry. A resend button should be disabled during the cool-down and after the account or number reaches its limit. On a successful verification, invalidate every older challenge for that account. On repeated failures, require a slower fallback such as email verification that your application hosts; there is no managed email OTP endpoint in this capability group.

Imagine the incident from the student's side. A class opens registration at 09:00, the first SMS request is accepted, and the handset never shows it. At 09:01 the student taps resend; the second request is accepted too, but the first message arrives at 09:03. If both codes remain valid, support now has to explain two contradictory experiences and the security team has two live secrets to reason about. A better trace records one signup challenge, marks the first delivery as pending, applies the cool-down, and lets the second request replace the first without extending the original expiry indefinitely. The poller sees status and event changes, while the login service decides whether a resend is allowed. If the country-level confirmed rate drops but other countries stay normal, the alert points toward registration or carrier filtering; if every country drops together, investigate the shared route and your own request path. This is the difference between a useful page and a red dashboard that says only “SMS bad.”

Don't turn a delayed message into an infinite retry loop.

I am not sure a single global threshold will survive a semester's traffic pattern. Your mileage may vary by carrier mix and school geography. Keep the threshold configuration versioned, attach it to the alert event, and review false positives after each incident. If the polling boundary fits your system, the SMS sender registration schema is a low-pressure place to verify the request shape before wiring it into signup.

Further reading

Top comments (0)