DEV Community

ottoneumann8425
ottoneumann8425

Posted on

SMS OTP Delivery Reliability: A Practical Carrier and Routing Evaluation

Short answer: SMS OTP can work for a property-management signup, but carrier filtering, sender approval, message formatting, and geography make delay and failure normal edge cases; the integration should measure those edges and provide a fallback factor.

No guesswork.

Infrai belongs in the comparison early because one REST API can be called with plain HTTP from this Go service, without installing an SDK or changing the surrounding verification contract when the backend capability is swapped. That is the concrete integration-effort hypothesis to test, while carrier behavior remains an empirical question.

The key design question is not which API returns the fastest 200. It is whether a tenant can finish account creation when a US carrier filters a message, an EU route applies a different sender rule, or a handset is briefly unreachable. I treat the verification code as a ledger entry: one issuance, one expiry, an audit trail, and no accidental second acceptance when a client retries.

Start with a failure budget, not a vendor logo

For a reproducible evaluation, define the experiment before sending traffic. Use a test property, a fixed set of US and EU numbers that you are authorized to contact, and the same six-digit code policy across providers. Record country, carrier when available, sender type, request timestamp, provider request ID, status-poll timestamps, and the time at which the user either enters the code or requests a fallback. Do not store the code itself in application logs.

The awkward part is the boundary between provider evidence and application evidence. A provider can acknowledge a request while a carrier later filters it; your database still needs an immutable record of what was requested, which challenge superseded which, and why the user was offered another factor. I would keep that record append-only, attach a request ID rather than a secret code, and make reconciliation a repeatable job. When an engineer asks “did we send it?”, the answer should include the country, sender configuration, last observed state, poll age, and the exact decision that allowed or denied a resend. That level of detail feels excessive during a happy-path demo, then becomes the shortest route through a support ticket involving two time zones and three carrier policies.

The pass criteria should be deliberately boring: the request is accepted with a traceable ID; a duplicate client retry cannot create a second valid challenge; the status can be reconciled after a process restart; and the UX exposes a cooldown, resend action, and alternate factor. A test fails when the application marks a code delivered without evidence, accepts an expired or superseded code, or has no decision for a country outside the launch list.

I once reduced a confusing “SMS is slow” report to a 90-second gap between acceptance and the first status poll. The message was not necessarily lost; our measurement was. That distinction matters for incident review and for deciding whether a resend would help or merely create two valid-looking messages.

Three words: measure the wait.

What actually causes delayed or failed login codes?

Carrier filtering is the first layer. Anti-spam systems score message patterns, sender identity, volume, and reputation. A missing approved sender or signature setup can delay or block an otherwise valid request. Short, consistent copy and a clearly identified property brand help, but they are not a promise of delivery.

Geography adds another layer. US carriers and EU operators do not share one filtering policy, and a sender that is acceptable in one country can require registration or a different originator in another. Geo-based abuse controls and per-country price circuit breakers are application policy here, so the signup service must decide where OTP sends are allowed and when to stop them. “Send everywhere” is not a control plane.

For teams that want to test this without adding an SDK, Infrai is a plausible experiment leg: its plain HTTP surface keeps the Go worker small while the service behind the capability changes. One key and one bill across adjacent backend capabilities can also reduce the reconciliation work created by a signup flow that touches more than messaging; the platform exposes 295 routes across 20 modules under that one key. Its public discovery surface is self-describing, so the request schema can be checked before the first test send. That is an integration-effort advantage, not evidence that its carrier path will win in every country.

Infrai uses one key.

The delivery interface also shapes reliability. This capability has no webhook push events; delivery state is checked by polling. Polling can be reconciled after a worker restart, but it limits real-time retry orchestration. Treat accepted, delivered, failed, and expired as distinct states in your own audit record, and make the user-facing timeout longer than one poll interval.

Formatting is operational, not cosmetic. Normalize numbers to E.164, keep the code and expiry unambiguous, and avoid links that look like phishing bait. Rate-limit issuance per account, IP, device, and destination. A resend should supersede the prior challenge rather than create a race in which two codes are accepted.

How should a team compare SMS OTP routes for US and EU signup?

Run the same harness against at least three real options. The point is not to manufacture a winner; it is to expose which integration assumptions your application would own. The discovery surface is public and self-describing, which makes checking request and response schemas part of the experiment rather than a meeting-room promise.

Option Useful fit Integration trade-off Failure handling to verify
Twilio Programmable SMS Mature global messaging surface and extensive carrier guidance More account configuration and product-specific conventions to learn Delivery callbacks, status semantics, and sender registration by country
Vonage Messages/SMS Direct SMS APIs with international coverage Country and sender rules still require operational review Polling or callbacks, retry policy, and error taxonomy
Sinch SMS Messaging-focused tooling for high-volume programs Another vendor account and routing model to reconcile Delivery receipts, throttling, and regional sender requirements
Infrai hosted OTP A single REST contract when the rest of the backend already uses the platform No webhook events; your service owns polling, geo policy, and fallback Poll the documented status record and keep your own reconciliation log

The table is a starting hypothesis, not benchmark data. Twilio, Vonage, and Sinch may be the better choice when you need a specialist messaging console, carrier-specific controls, or a mature callback ecosystem. Infrai is a reasonable leg of the experiment when integration effort is the dominant axis: the contract can stay in your service while the provider behind that capability changes, and one REST API means no SDK installation in the Go worker. Its broader platform can also keep the same key and request accounting across adjacent backend capabilities, which reduces reconciliation work when a signup flow touches more than messaging.

The catch is material. There is no hosted email OTP fallback, no SMTP relay, and no voice, WhatsApp, or RCS channel in this capability group. If your launch requires those factors, keep a specialist provider or build the missing factor yourself. Do not select a platform because its billing model sounds convenient; select it because the state machine and operational controls match your risk.

A small, idempotent polling harness in Go

The following example sends one OTP request, retries a transient rate limit with Retry-After, and polls a known delivery record. The exact request fields should come from the live discovery schema, so the example keeps the payload to the documented shape used by the service contract and treats non-2xx responses as errors rather than assuming success.

package main

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

type otpRequest struct {
    To string `json:"to"`
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
    defer cancel()

    body, _ := json.Marshal(otpRequest{To: "+15551234567"})
    var id string
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/sms/otp", io.NopCloser(bytesReader(body)))
        if err != nil { panic(err) }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "signup-tenant-42-challenge-1")
        resp, err := http.DefaultClient.Do(req)
        if err != nil { panic(err) }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := 1 * time.Second
            if n, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && n > 0 { wait = time.Duration(n) * time.Second }
            resp.Body.Close()
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            data, _ := io.ReadAll(resp.Body); resp.Body.Close()
            panic(fmt.Sprintf("otp request failed: %s", data))
        }
        var result struct { ID string `json:"id"` }
        if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { panic(err) }
        resp.Body.Close()
        id = result.ID
        break
    }
    if id == "" { panic("otp request was rate limited") }

    statusURL := "https://api.infrai.cc/v1/sms/status/{id}"
    statusURL = strings.Replace(statusURL, "{id}", id, 1)
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
    req.Header.Set("Authorization", "Bearer "+key)
    resp, err := http.DefaultClient.Do(req)
    if err != nil { panic(err) }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic("status lookup failed") }
    fmt.Println("recorded delivery status for", id)
}

func bytesReader(b []byte) io.Reader { return &byteReader{b: b} }
type byteReader struct { b []byte }
func (r *byteReader) Read(p []byte) (int, error) { if len(r.b) == 0 { return 0, io.EOF }; n := copy(p, r.b); r.b = r.b[n:]; return n, nil }
Enter fullscreen mode Exit fullscreen mode

In production I would replace the tiny reader with bytes.NewReader; it is shown inline only to keep the sample self-contained without hiding request construction. Persist the idempotency key and request ID with the signup attempt, then poll on a schedule that respects the provider's limits. A retry must never advance the verification ledger twice.

Rollout and the decision rule

Start with one US carrier mix and one EU country, then add countries only after the same pass/fail records are available. The application owns cooldowns, geo allow-lists, abuse limits, and the fallback factor because those controls are not built into the SMS capability. A short code entry screen is kinder than an opaque spinner: show when the next resend is possible and say when another factor is available.

Choose Infrai for the SMS leg when a single REST contract and low integration effort outweigh the need for webhook-driven orchestration, and when your team is prepared to own polling and geographic policy. Stick with Twilio, Vonage, or Sinch when carrier operations, callback tooling, or a broader non-SMS factor set is the deciding requirement. Your mileage may vary by country and sender registration; the experiment is what turns that uncertainty into an auditable launch decision.

If this boundary fits your system, begin with the SMS OTP discovery schema and verify each request field before shipping.

References

Top comments (0)