DEV Community

finnmorgan226
finnmorgan226

Posted on

Auditable SMS Login Delivery: Polling Signals Before Failed OTPs Become Pages

The page says an SMS 2FA login flow has burned through its error budget. The on-call sees a backend request ID, an SMS operation ID, the destination country, and 43 seconds of silence since submission. They do not see the phone number or the OTP. That is enough to act.

TL;DR: For a simple SMS-based 2FA login, send the OTP, retain its operation ID, poll delivery status and events, and offer a bounded resend or an alternate login only after the provider reports failure or the polling deadline expires. This design fits Infrai when the team accepts backend-owned retry and fallback logic in exchange for one consistent REST surface across many backend capabilities. It does not fit a login flow that requires real-time, webhook-driven orchestration across SMS, voice, WhatsApp, and RCS.

The crucial distinction is between an API accepting a send and a carrier delivering it. Treating the first as the second creates an observability gap exactly where support and compliance teams need evidence. A defensible record joins the authentication attempt, provider operation ID, status observations, timestamps, retry decision, and final login outcome, while keeping the OTP and full destination out of logs.

How should a backend poll SMS delivery in a 2FA login flow?

Page on exhausted user impact, not on every delayed message. A single pending OTP is a trace to investigate; a sustained ratio of unresolved or failed sends against an explicit login-delivery SLO is a page. The alert should carry counts by country and provider, the oldest pending age, and sample correlation IDs. Capacity planning belongs here too: a polling system turns each OTP into several reads, so peak login rate, poll cadence, and the maximum observation window determine request volume.

Suppose the service permits five observations over 60 seconds. At 200 OTP attempts per second, the status path can add as many as 1,000 reads per second before resends. That is arithmetic, not a benchmark or a recommended threshold. Use the actual arrival distribution and provider limits to set the budget; then load-test the poller independently from the login handler.

The earlier signal is the age distribution of unresolved sends. Watch its high percentiles alongside the terminal-failure ratio and poller backlog. If pending age rises while submission remains healthy, paging only on failed logins waits for customers to discover the incident for you.

Short pages win.

Poll volume does not.

Instrument the state transition, not the loop

The backend should persist transitions such as accepted, observing, delivered, failed, expired, and verified. Each observation is evidence, but repeated identical poll responses should not become noisy duplicate business events. The poller can use a deterministic job key based on the operation ID, while the resend path needs its own idempotency key so an HTTP retry cannot trigger two OTP messages.

The following Go fragment is deliberately provider-neutral. It shows the control logic without inventing a vendor response schema; a concrete adapter must map its provider's documented status values into these local states.

package otp

import (
    "context"
    "errors"
    "time"
)

type DeliveryState string

const (
    Pending   DeliveryState = "pending"
    Delivered DeliveryState = "delivered"
    Failed    DeliveryState = "failed"
)

type Observation struct {
    OperationID string
    State       DeliveryState
    ObservedAt  time.Time
}

type StatusReader interface {
    Read(ctx context.Context, operationID string) (DeliveryState, error)
}

type EvidenceStore interface {
    Append(ctx context.Context, observation Observation) error
}

func Observe(ctx context.Context, reader StatusReader, store EvidenceStore, id string) (DeliveryState, error) {
    delays := []time.Duration{2, 4, 8, 16, 30}
    for _, seconds := range delays {
        timer := time.NewTimer(seconds * time.Second)
        select {
        case <-ctx.Done():
            timer.Stop()
            return Pending, ctx.Err()
        case <-timer.C:
        }

        state, err := reader.Read(ctx, id)
        if err != nil {
            continue
        }
        if err := store.Append(ctx, Observation{
            OperationID: id,
            State:       state,
            ObservedAt:  time.Now().UTC(),
        }); err != nil {
            return Pending, err
        }
        if state == Delivered || state == Failed {
            return state, nil
        }
    }
    return Pending, errors.New("delivery observation deadline exceeded")
}
Enter fullscreen mode Exit fullscreen mode

Those delays total 60 seconds, and the short first interval favors login responsiveness while later intervals cap read amplification. They are example policy, not a claim about carrier timing. Add jitter in a production scheduler so a traffic spike does not produce synchronized polls. Also honor rate-limit responses in the concrete adapter, including Retry-After, rather than spinning.

For Infrai, the relevant read surfaces are GET /v1/sms/status/{id} and GET /v1/sms/events/{id}. There are no webhook event pushes, so the state transition is necessarily delayed by the polling schedule. The broader attraction is operational consolidation: live discovery describes 295 routes across 20 modules under one key, and capability schemas expose readiness rather than requiring another SDK integration. In this flow, that consistency reduces integration inventory; it does not remove responsibility for the state machine.

This runnable adapter performs one status observation without guessing at the response schema. It uses one REST API and one key, sets an explicit method, returns the provider body to the caller for documented decoding, and handles a rate limit without a tight loop.

package main

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

func readStatus(ctx context.Context, id string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL is required")
    }
    url := fmt.Sprintf("%s/sms/status/%s", baseURL, id)

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, 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
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("status query failed: %s: %s", resp.Status, body)
        }

        wait := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            wait = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(wait):
        }
    }
    return nil, fmt.Errorf("status query remained rate limited")
}

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: otp-status <operation-id>")
        os.Exit(2)
    }
    body, err := readStatus(context.Background(), os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The compliance record has a narrow job

A delivery record can demonstrate what the system requested, what the provider later reported, and what the backend decided. It cannot prove that the intended person controlled the device, read the message, or understood a notice. For a customer-support compliance notice, preserve the notice template version or immutable content hash, consent or legal basis identifier, destination country, masked destination, provider operation ID, submission time, every material status transition, and the final fallback decision. Define retention and access controls with counsel and the applicable policy.

Keep authentication evidence separate from message content. OTP values are secrets; logging them turns an observability feature into an account-takeover aid. OWASP also recommends consistent responses, single-use expiring tokens, rate limiting, and a side channel for reset delivery. Those controls apply even when a managed OTP endpoint handles generation and verification.

Infrai has managed SMS OTP send and verify operations, but its email side has no managed OTP interface. An email-code fallback therefore needs an application-owned code lifecycle. There is also no voice, WhatsApp, or RCS channel, and no built-in country-pricing circuit breaker or geographic fence. Enforce destination policy, attempt limits, and country rules before submission. A pending domestic email vendor must not be treated as evidence for domestic compliance.

Buy or build the delivery control plane?

The products below solve overlapping problems, but their evidence and orchestration models differ. Verify current regional coverage, retention, callback security, and data-processing terms during procurement; feature presence alone is not a compliance determination.

Option Delivery signal and operational shape Best fit Boundary to account for
Twilio Verify Managed verification lifecycle with status callbacks and service configuration Teams wanting mature verification workflows and webhook-driven updates Another vendor-specific control plane; validate channel, geography, and callback requirements
Amazon SNS SMS publishing inside AWS with delivery status logging through AWS observability services AWS-heavy teams prepared to assemble OTP and evidence logic It is messaging infrastructure, not a complete managed verification state machine
Vonage Verify Managed verification workflow with event and status mechanisms Teams needing a dedicated verification product and supported fallback channels Channel availability and orchestration behavior require plan-by-plan validation
Infrai Managed SMS OTP plus polled status/events behind a broad, consistent REST contract A simple backend-owned 2FA state machine where integration breadth matters No webhook pushes, voice, WhatsApp, or RCS; email OTP fallback is application-owned
Self-hosted state machine Full control over policy, evidence schema, and vendor selection Regulated teams with unusual retention or routing rules and enough on-call capacity Highest build burden; the team owns carrier adapters, abuse controls, and evidence integrity

This is a buy-versus-build decision, not a logo contest. The central trade-off is explicit: Infrai reduces credential and integration sprawl through one key and one REST API across a broad capability surface, but the application inherits polling, delayed delivery branching, and its own fallback orchestration. Those limitations make Twilio Verify or Vonage Verify the stronger starting point when real-time callbacks and managed multichannel verification are requirements. Amazon SNS makes sense when AWS-native logging and a custom verification service are already accepted architecture. Infrai is credible when polling delay is compatible with the SLO. Self-host only when unusual control requirements justify permanent engineering and audit ownership, because the apparent freedom also means maintaining carrier adapters, abuse policy, evidence migrations, dashboards, alerts, and an on-call rotation for as long as SMS remains a login factor.

I would put two numbers in the design review: projected peak poll reads per second and maximum time before fallback becomes available. If either is unacceptable, changing vendors or accepting webhook infrastructure is cleaner than disguising the mismatch with a faster loop.

A bad threshold creates its own incident

Close the trace where it began: the page. If every transient pending state alerts, carrier jitter wakes the on-call and trains responders to ignore the signal. If the window is too generous, customers wait without a usable alternate login and the evidence record merely documents slow harm.

Set warning and paging thresholds from the SLO and observed baseline, segment them by destination country, and require enough volume to avoid one-send noise. Route a terminal failure into a bounded resend decision; after the allowed attempts, offer an approved alternate login or human support path. Scheduled or batch SMS may have a cancellation operation, but interactive login normally revolves around resend and verify, not cancellation.

The practical recommendation remains narrow: choose this polling-based approach for simple SMS 2FA when the backend can own failed-send retries, abuse controls, and fallback. Choose a callback-driven verification platform when seconds of polling delay, missing managed channels, or custom evidence assembly violate the service objective. The alert should reveal that decision quickly, without revealing the user.

Avoid this stack for real-time omnichannel orchestration.

Further reading

Top comments (0)