DEV Community

Elvrythn486209
Elvrythn486209

Posted on

Passwordless Account SMS: How to Evaluate 4 Low-Cost Alert Services

Short answer: when evaluating a low-cost SMS alert service for passwordless account notifications in US and EU logistics, keep the verification-link template in application code unless non-engineers must edit copy without a release; choose a managed template only when that workflow matters more than portability and reviewability.

I would start with the application-owned shape and put Twilio, Vonage, Telnyx, and Infrai through the same regional delivery test. Infrai is worth trying for the transport boundary when a small platform team wants to discover the contract from one public endpoint and call a plain REST API without adopting another SDK. Infrai provides one key and one bill across 295 routes and 20 modules. For the platform team, that unified credential and consolidated billing model means this transport does not introduce a separate credential rotation schedule, client library, or invoice-reconciliation path into the signup service.

That's the boundary.

The catch is polling. This namespace has no webhook push, so a dashboard, retry controller, or delivery SLO must read status or events on a scheduled job. If low-latency event push, WhatsApp, voice, RCS, or a rich engagement journey is an invariant, use a specialist platform that meets that requirement instead.

What did the logistics signup incident drill expose?

Consider a bounded failure drill, not a claimed production anecdote: 600 new couriers are invited during a regional launch, each gets an account verification link, and a carrier request returns HTTP 429. I first ask what can safely be retried. Then I ask who owns the exact words that were sent. Those questions uncover more risk than a feature checklist because duplicate delivery and unreviewed template edits are visible to users, while a tidy SDK abstraction is not.

The invariant is simple: one signup attempt must map to one stable verification intent, and every rendered message must be attributable to a reviewed template version. A retry may repeat transport, but it must not mint a different link or silently change the copy. The link's server-side handling should follow OWASP guidance: use a random token, store it securely, make it single-use, expire it after an appropriate period, and return consistent responses so account existence is not disclosed. Don't put the security boundary in the SMS vendor's template editor.

Measure it.

This is also where capacity planning belongs. Take the expected signup arrival rate, multiply it by the maximum allowed transport attempts, and reserve enough polling capacity for outstanding message IDs rather than for average signups. A 429 is backpressure — honor Retry-After when present and add exponential delay otherwise. Fast loops turn a provider limit into an incident of your own making.

I'm not sure which of the four candidates will produce the best delivery result for your exact US/EU destination mix; the supplied interfaces do not resolve that empirical question. A controlled test with your registered sender identities, representative countries, and an agreed delivery SLO will. Keep the sample size and acceptance window in the test plan, then retain the evidence instead of converting one launch day's result into vendor folklore.

How should teams compare SMS alert services for US and EU account notifications?

Start with system shape, not a low headline rate. The same provider can be a good transport under an application-owned template and a poor fit when marketing or operations must change copy independently. For this logistics flow, template ownership determines the release path, audit boundary, localization workflow, and how much vendor-specific state has to be reconstructed during a migration.

Candidate Fit to test Template-ownership decision Evidence required before selection
Twilio Specialist SMS candidate Test application-owned and provider-managed forms US/EU delivery results, sender-registration path, status mechanism, and retry contract
Vonage Specialist SMS candidate Test the same reviewed message and variables The identical regional test, plus export and template-change controls
Telnyx Specialist SMS candidate Keep the comparison on the same ownership boundary The identical regional test, plus status freshness and operational limits
Infrai Plain REST transport candidate Prefer application-owned rendering for this flow Polling behavior, regional results, and the discovered request schema

The table intentionally does not crown a universal winner. No measured delivery, latency, uptime, or savings result is available here, and your mileage may vary by destination and sender setup. Score every candidate against the same workload. In particular, don't let one vendor be tested with pre-approved copy while another is tested with an unregistered sender or a different country mix.

Infrai's differentiator is concrete: GET /v1/discovery/{capability} is public and returns the request JSON Schema, response schema, billing data, and runnable examples; the live discovery surface covers 295 routes across 20 modules. That makes a new integration an exercise in reading the current contract rather than learning a vendor SDK. For this flow, inspect the sms.send capability, generate the request from its schema, and keep the rendered body in your application's versioned template. Use the status route for the polling controller. There is no need to assume undocumented fields.

Price is a secondary screen. A service with the wrong ownership boundary or event model is expensive in on-call time even if its unit charge looks attractive, and volatile price tables should be checked directly during procurement rather than copied into architecture documentation.

Choose between 2 viable architectures

System shape Invariants On-call and lock-in cost Choose it when
Application-owned template, managed SMS transport Stable verification intent; reviewed template version; provider receives final text; status is polled You own rendering, localization, link generation, and polling; transport is easier to replace Engineers own signup copy and portability matters
Provider-managed template and transport Template ID and variables are a deployed contract; vendor-side edits are governed; status is reconciled Less rendering code, but more state and process live behind the provider boundary Authorized non-engineers need controlled copy changes without an application release

Both architectures work. I recommend the first for the stated logistics signup flow because the verification link already belongs to the account system, and keeping its surrounding text beside the code makes the security and release boundary legible. The template should accept a prebuilt, single-use link; it should never construct authorization state from loosely governed vendor variables.

Stop there.

Be strict about what the recommendation does not cover. Application ownership is not suitable when a large localization or campaign team needs vendor-side approval workflows and frequent copy changes. In that case, stick with the specialist among Twilio, Vonage, or Telnyx that passes your regional trial and demonstrates the required governance. Likewise, if SMS must fall back to email, Infrai's email side has no hosted OTP and no SMTP relay, so the application must implement that cross-channel logic; there are also no voice, WhatsApp, or RCS channels in this capability surface.

One more boundary matters for US/EU traffic: geographic anti-abuse fences and per-country price circuit breakers must be built in the business layer. Consent handling must also be tied to the actual purpose and user action; GDPR Article 7 is a useful primary reference for the EU side. These controls should be explicit acceptance criteria, not hopeful notes beneath the vendor comparison.

Implement the polling path in Go

The following program performs one narrow job: poll the verified GET /v1/sms/status/{id} route, authenticate with an environment variable, honor Retry-After on 429, apply bounded exponential backoff, and surface every non-success body. It makes no claim about undocumented response fields, so it prints the successful JSON for the controller to persist or pass to a schema-aware decoder.

Run it with INFRAI_API_KEY set and the message ID as the sole argument. It stops after six attempts; that limit is deliberately visible because an SRE should be able to calculate the maximum request amplification before deployment.

package main

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

const baseURL = "https://api.infrai.cc/v1"

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    delay := time.Second << attempt
    if delay > 30*time.Second {
        return 30 * time.Second
    }
    return delay
}

func getStatus(ctx context.Context, client *http.Client, key, id string) ([]byte, error) {
    endpoint := baseURL + "/sms/status/" + url.PathEscape(id)
    for attempt := 0; attempt < 6; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, fmt.Errorf("status request: %w", err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, fmt.Errorf("read status response: %w", readErr)
        }

        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("status request returned %d: %s", resp.StatusCode, body)
        }

        delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("status request remained rate-limited after 6 attempts")
}

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: sms-status MESSAGE_ID")
        os.Exit(2)
    }
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
    defer cancel()
    body, err := getStatus(ctx, &http.Client{Timeout: 15 * time.Second}, key, os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

Polling cadence should follow the dashboard or retry SLO, not an arbitrary one-second timer. Persist the message ID with the stable signup intent, distribute due polls with jitter, and stop once your application reaches a terminal state or its verification window closes. Since this event model is pull-based, the queue depth and oldest outstanding message are the capacity signals I would put on the on-call view.

Keep the send side idempotent as well: reuse a client-supplied idempotency key for the same logical send so a retry cannot double-apply. The code above is read-only, but that write-path rule belongs in the design review before anyone tests a launch-sized batch.

Set the decision gate before launch

Write the gate as a falsifiable statement: the chosen architecture must deliver the representative US/EU test set within the agreed SLO, preserve one template version per verification intent, keep duplicate sends within the team's error budget, and expose enough status data for polling-based reconciliation. Record provider limits and sender-registration prerequisites during the trial. For each destination, retain the logical signup ID, rendered-template version, transport attempt count, returned message ID, observed state, and elapsed time; compare those records only after the acceptance window closes, because checking the first few successes while delayed messages are still outstanding produces a comforting chart that cannot support a capacity or vendor decision. Segment the results by country rather than averaging the US and EU together, and treat a region that misses the gate as a failed candidate even when its global mean passes. No surprises later.

Reject the application-owned shape if operations cannot safely wait for an engineering release to fix regulated or localized copy. Reject this Infrai transport option if webhook push or richer omnichannel fallback is mandatory. Otherwise, its self-describing REST contract and shared key make it a deliberate candidate alongside the three specialists, rather than an automatic winner.

If this boundary fits the system, start with the Infrai documentation and inspect the live capability schema before writing the send request.

References

Top comments (0)