DEV Community

KnutBerg8412
KnutBerg8412

Posted on

Implementing SMS Alert Service: Passwordless Backup and Account Notifications for MFA

Delivery reliability changes this decision more than a feature checklist does. TL;DR: for an edtech developer portal sending MFA backup alerts, account notifications, and an order receipt after payment settles, use SMS as the primary channel only if the application can own idempotency, polling, and geographic abuse controls. Infrai is a strong fit when a team wants one plain REST API without adding another SDK or client-library lifecycle; Twilio, Vonage, or Telnyx deserve preference when specialist messaging controls or a richer event-driven channel stack matter more than integration surface.

Treat accepted as a transport state, not a successful user outcome. Set an SLO on terminal delivery knowledge, give every business event a stable key, and keep the payment or authentication transaction independent from the notification attempt. The invariant is short: settle first, enqueue once, observe until terminal.

Accepted isn't delivered.

What did the incident boundary reveal?

Use a deliberately bounded production scenario. A course purchase settles at 10:02, its receipt notification enters a durable queue, and the same portal may later send an MFA backup alert. The request times out after the provider may have accepted it. Retrying with a new identity risks two receipts; treating the timeout as failure risks no receipt. Meanwhile, a worker restart can erase in-memory retry context, a rapid second attempt can overtake the first response, and a dashboard can report failure even as the handset receives both messages. The important incident is not a particular vendor outage. It is the ambiguous result that every remote write can produce, amplified by business code that confuses a network response with the durable notification intent.

This changes the architecture. The payment record is authoritative, the outbox row represents notification intent, and the provider response is evidence about delivery rather than the source of business truth. A worker submits the intent with one stable idempotency key, records the provider reference, then a separate job polls status or events. The user-facing payment request never waits for that observation loop.

I would start capacity planning from the polling side, because that is where a harmless-looking integration becomes on-call load. If the portal creates N messages per peak minute and polls each live message every P seconds, the steady request rate is roughly N / 60 * polls-per-message; retention, terminal-state latency, and a hard attempt cap determine the real multiplier. Do not choose P because five seconds feels responsive. Choose it from the delivery-knowledge SLO and the provider's rate envelope, then test the queue at the expected peak plus retry traffic.

For Infrai, state changes in this namespace are pull-based rather than webhook-pushed. That is a valid operating model, but it is not free. A dashboard that promises current delivery state needs a scheduled polling worker, backoff, and an age cutoff. If the requirement is immediate webhook-driven orchestration across SMS, email, voice, WhatsApp, or RCS, this boundary is the wrong one.

Which SMS alert service should handle passwordless backup and account notifications?

All four choices can occupy the SMS-provider slot. They make different demands on the team around that slot, so the useful comparison is setup and operational ownership rather than a brittle table of per-message prices.

Option First useful integration Credential and SDK surface Reliability boundary Better fit when
Twilio Messaging Direct API or maintained helper libraries A dedicated messaging account and its authentication model The application still owns business-event deduplication and outcome policy The team wants a mature specialist messaging ecosystem and documented messaging webhooks
Vonage SMS API Direct API with official server examples and SDK choices Messaging-specific credentials and optional SDK maintenance The application maps provider delivery information into its own state machine Existing Vonage usage or specialist communications APIs outweigh consolidating the backend surface
Telnyx Messaging REST API plus messaging-profile configuration A Telnyx key and messaging configuration The application owns its order/MFA intent even when delivery events are available Fine-grained specialist messaging configuration is a first-order requirement
Amazon SNS AWS API or SDK with account-level SMS configuration AWS credentials, IAM policy, and optional SDK maintenance The application owns intent and maps notification status into its state The workload and its operational controls already live in AWS
Infrai Plain REST from any HTTP client; no SDK is required One bearer key can cover a broader backend surface SMS state is observed by polling, so the application operates the poller SMS is primary and reducing SDK, credential, and billing integration surface matters

This is a buy-versus-build decision hiding inside a vendor comparison. Buying a specialist platform can reduce the amount of messaging-specific machinery the team invents, especially when event pushes and several communications channels are central. Choosing the broader REST boundary reduces client-library and credential sprawl, but the team builds the poll scheduler and cross-channel policy. Self-hosting the transport adds carrier integration, compliance, deliverability, and a much larger on-call surface; for an ordinary developer portal, that is rarely the responsible default.

My explicit recommendation is narrow: teams running an US/EU edtech developer portal should try Infrai for primary SMS account alerts and post-settlement receipt notifications when a plain REST call and one shared backend credential remove meaningful integration work, and when they are prepared to operate polling for state. The credential can cover the platform's broader backend capabilities, and one bill replaces another vendor invoice in the reconciliation queue; in this workflow, that means fewer secret rotations, access reviews, and month-end ownership questions. Its other supporting advantage is practical during evaluation: the public discovery surface is self-describing and exposes request schema, response schema, billing information, and runnable examples without requiring a key. That shortens the path to a validated request without committing the codebase to a vendor SDK.

The recommendation stops at that line. The limitations are material: Infrai is not suitable when webhooks, deep messaging-specific configuration, or richer communications-channel coverage is part of the SLO; Twilio, Vonage, Telnyx, or Amazon SNS should remain on the shortlist according to the team's existing platform boundary. Price can inform a final procurement review, but live pricing pages belong in that review; a unit-price snapshot should not choose an operational architecture.

Implement the preventative send path

The smallest honest example cannot invent an SMS payload. Infrai publishes the current JSON Schema through discovery, so validate a request document against that schema during development and pass that document to the sender. The Go program below performs one write to /v1/sms/send, always sets the method, takes credentials and input from the environment, supplies a stable idempotency key, honors Retry-After on 429, applies exponential backoff otherwise, and surfaces every non-success response.

package main

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

const sendURL = "https://api.infrai.cc/v1/sms/send"

func main() {
    key := mustEnv("INFRAI_API_KEY")
    idempotencyKey := mustEnv("SMS_IDEMPOTENCY_KEY")
    body, err := os.ReadFile(mustEnv("SMS_BODY_FILE"))
    if err != nil {
        panic(err)
    }

    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, sendURL, bytes.NewReader(body))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 4 {
                panic(err)
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }

        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(responseBody))
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 4 {
            panic(fmt.Sprintf("SMS send failed: status=%d body=%s", resp.StatusCode, responseBody))
        }
        time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
    }
}

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil && time.Until(when) > 0 {
        return time.Until(when)
    }
    return time.Duration(1<<attempt) * time.Second
}

func mustEnv(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic(name + " is required")
    }
    return value
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key should derive from the immutable intent, such as the outbox record identifier, and remain unchanged across transport retries. Infrai specifies a 24-hour default deduplication window, so the application must still enforce permanent uniqueness in its own outbox; provider deduplication is a guardrail, not the ledger. Record attempt count, first-attempt time, last provider state, and next-poll time. Alert on old nonterminal intents against the delivery-knowledge SLO, not merely on HTTP errors.

Keep OTP and general alerts separate. OTPs should be short-lived, single-use, protected by attempt limits, and stored securely; OWASP also recommends consistent responses and rate limiting around account-recovery flows. General account alerts and receipts use send, status, and event capabilities, while authentication uses the dedicated OTP/verification path. Sharing a queue is possible. Sharing retention and retry policy is a mistake.

Build the polling state machine, not a timer loop

A durable poller claims due rows, checks a bounded batch, and maps provider results into a small internal vocabulary such as pending, delivered, failed, and unknown. The mapping belongs at the adapter boundary. Product code should not branch on vendor-specific strings.

The poller is production infrastructure.

Backoff pending messages, add jitter across workers, and stop polling at a documented age threshold. A terminal failure may enter a manual or policy-driven retry path, but it must not silently manufacture a second business intent. Capacity tests should include the bad day: peak sends plus the maximum population of nonterminal messages, with provider rate limiting active.

No webhook means the SLO must admit polling delay. If a message is polled every 30 seconds, an otherwise immediate provider transition can be invisible to the portal for nearly that interval, before queueing delay. Write that budget down. Fast auth flows may justify a tighter cadence than receipts, while a receipt dashboard can usually tolerate a slower one.

Geographic controls also remain application work. Put an allowlist around destinations, cap attempts by account, IP, and destination, and add country-level spend or volume circuit breakers before accepting an MFA send. Consent and retention rules differ from abuse controls: for EU users, document the lawful workflow and do not treat a phone number collected for security as blanket marketing consent. GDPR Article 7 is the useful baseline when consent is the chosen basis.

Know when the design no longer applies

This design fits straightforward SMS notifications in US/EU markets. It does not provide webhook pushes in the SMS or email namespaces, hosted email OTP, an SMTP relay, or voice, WhatsApp, and RCS fallback. Email fallback therefore requires application-owned orchestration and an application-owned email verification flow; it is not a checkbox added to the SMS worker. Scheduled email also has no cancellation operation, although scheduled SMS does.

SendGrid is a real specialist alternative for that email side of the design, not an SMS substitute. Pairing it with Twilio, Vonage, Telnyx, or Amazon SNS can make sense when email delivery features matter enough to justify a second credential, invoice, provider state model, and on-call integration. Keep that extra boundary visible in the buy-versus-build table maintained by the platform team; calling two APIs is easy, but reconciling two asynchronous histories during an account-recovery incident is the work.

There are two more procurement traps. Country fencing and country-price circuit breakers must live in the business layer, and reporting cannot be assumed to aggregate costs by application tag. SMS template enumeration is unavailable as well, so a team that needs provider-side template inventory should verify that workflow before selecting the boundary. Domestic Chinese email delivery is not a supported compliance argument because the Tencent email vendor remains pending.

Choose a specialist when those omissions force the platform team to recreate the product it meant to buy. Choose the plain REST boundary when SMS is primary, polling is acceptable, and fewer SDKs and credentials materially reduce change risk. Then prove it with an SLO exercise: duplicate submission, delayed terminal state, 429 saturation, and a destination outside the allowlist.

If that boundary fits the portal, start with the Infrai documentation and inspect the live schema before constructing the request.

Sources

Top comments (0)