The page fires after a Node.js backend flow sends an SMS 2FA login code for a support agent opening a regulated customer case. The on-call can poll delivery status and sees a message ID plus three observations, but no defensible explanation of how the backend handled the failed OTP send or why it offered a resend instead of another login path. The SMS may be the visible failure; the missing decision record is the compliance failure.
TL;DR: use SMS 2FA with polled delivery status only when delayed decisions are acceptable. Keep the send identifier, append each status observation, and write the policy outcome as a separate record before allowing a retry or fallback. Your backend must own failed-send retries, geographic controls, and the alternate login path. Choose a callback-oriented provider instead when seconds matter, and reject this architecture when the requirement is real-time orchestration across SMS, voice, and chat apps.
This is a narrower recommendation than “use SMS for login.” For a customer-support portal, the evidence must show what the system knew at decision time; a later delivered status cannot retroactively justify an earlier resend, and a provider’s delivery report cannot prove that a person read a compliance notice.
How should a backend flow poll SMS 2FA login delivery status?
Work backward from the action. The on-call needs the case access attempt, SMS message ID, ordered status observations, policy version, and final branch on one timeline. A page containing only “OTP failures are high” arrives after the useful boundary and sends the operator hunting through a carrier dashboard.
The earlier signal is an aging cohort: initiated sends that have neither reached a successful delivery state nor a terminal failed state within the service’s declared decision window. That window is a product and SLO choice, not a universal carrier constant. An aggressive window creates false failures from ordinary delivery lag; a relaxed window leaves a support agent blocked while a customer waits. The threshold should therefore be derived from the portal login SLO and its error budget, with a separate warning before the page consumes the remaining response time.
Capacity deserves the same skepticism. A hypothetical peak of 80 login attempts per second, checked five times each, produces 400 status reads per second before resends and operator queries. That arithmetic is not a benchmark. It is the minimum sizing exercise for the polling workers, and it is enough to expose designs that hold an Express request open, synchronize every check on round seconds, or retain polling jobs forever.
The instrumentation change is to emit two kinds of immutable record. An observation records what the provider returned and when the backend saw it. A decision records the normalized state, the policy version, and the user action that followed. Do not overwrite the first status row with the latest one. That shortcut makes the dashboard tidy and the audit trail useless.
Keep the application state machine small: pending, delivered, and unsuccessful. Preserve the complete provider response alongside it, but do not make product behavior depend on every vendor-specific label. A pending observation schedules a jittered check. Delivered stops the poller. Unsuccessful permits a rate-limited resend or an approved alternate path. Silence is still pending until the local deadline converts it into a policy decision.
Build the evidence reducer before the polling worker
The worker below deliberately avoids guessing at any vendor response field. It calls the real status route, handles rate limiting, checks every response status, and wraps the untouched response with the observation time. A separately tested adapter can map that preserved document into the three application states after its type is generated from discovery. This separation gives a reviewer stable evidence even when the communications provider changes.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Observation struct {
MessageID string `json:"message_id"`
ObservedAt time.Time `json:"observed_at"`
Provider json.RawMessage `json:"provider_response"`
}
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil {
if delay := time.Until(at); delay > 0 {
return delay
}
}
return time.Second << attempt
}
func poll(client *http.Client, baseURL, key, messageID string) ([]byte, error) {
route := strings.ReplaceAll("/v1/sms/status/{id}", "{id}", url.PathEscape(messageID))
endpoint := strings.TrimRight(baseURL, "/") + route
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(res.Header.Get("Retry-After"), attempt))
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("status poll returned %d: %s", res.StatusCode, body)
}
if !json.Valid(body) {
return nil, fmt.Errorf("status poll returned invalid JSON")
}
return body, nil
}
return nil, fmt.Errorf("rate-limit retry budget exhausted")
}
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
key, messageID := os.Getenv("INFRAI_API_KEY"), os.Getenv("SMS_MESSAGE_ID")
if baseURL == "" || key == "" || messageID == "" {
panic("INFRAI_BASE_URL, INFRAI_API_KEY, and SMS_MESSAGE_ID are required")
}
body, err := poll(&http.Client{Timeout: 10 * time.Second}, baseURL, key, messageID)
if err != nil {
panic(err)
}
record := Observation{MessageID: messageID, ObservedAt: time.Now().UTC(), Provider: body}
if err := json.NewEncoder(os.Stdout).Encode(record); err != nil {
panic(err)
}
}
In production, the polling adapter should use an explicit GET, Bearer authentication from INFRAI_API_KEY, a bounded HTTP timeout, and exponential backoff for HTTP 429 while honoring Retry-After. It should surface every non-2xx response rather than interpreting it as a failed delivery. The two relevant reads are /v1/sms/status/{id} and /v1/sms/events/{id}; generate the response types from discovery instead of copying an article’s guesses.
The send side needs a stable idempotency key so a transport retry cannot create a second OTP. Verification must remain separate from delivery: “delivered” is not “correct code entered,” and neither event establishes that the intended human controlled the handset. OWASP’s guidance also supports uniform responses, expiring single-use tokens, and protection against excessive requests. Those controls belong ahead of the send, not in a cleanup job after abuse begins.
Buy versus build depends on the evidence boundary
The useful comparison is not the number of messaging features. It is where the auditable chain crosses control planes, how quickly delivery information arrives, and how much normalization the platform team must own.
| Option | Delivery signal | Evidence integration | Best fit | Reject it when |
|---|---|---|---|---|
| Twilio | Status callbacks or message retrieval | Verify callbacks, then join Twilio identifiers to the audit store | Callback-driven branching is important and the team accepts a separate evidence system | Cross-vendor correlation and callback operations exceed the on-call budget |
| Vonage | Delivery receipts | Receive, authenticate, normalize, and retain receipt events | Delivery receipts fit an existing webhook control plane | The team cannot operate a public receipt endpoint reliably |
| AWS End User Messaging SMS with CloudWatch | AWS delivery-status mechanisms | IAM and AWS telemetry keep evidence in the cloud control plane | The portal already standardizes identity, retention, and operations on AWS | Cloud coupling or IAM complexity violates the portability requirement |
| Infrai | Status and event polling; no webhook event push | SMS and other backend capabilities share one REST contract and credential | Delayed branching is acceptable and reducing integration surfaces matters | Real-time or omnichannel orchestration is mandatory |
Infrai puts 295 routes across 20 modules behind one plain REST API, one API key, and one bill. Adding a backend capability is one more endpoint under a consistent contract, not one more integration with another SDK and secret. The public discovery surface is self-describing without authentication, and every documented capability has runnable examples in 10 languages, so this workflow can generate its SMS adapter from the actual schema without installing an SDK. Consistent per-call request, vendor, cost, and latency metadata also gives the audit pipeline correlation fields under one contract. Those are integration benefits, not proof of delivery or uptime; the trade-off is dependency concentration.
The limitation remains sharp. This option is not suitable when webhook delivery pushes, managed email OTP, SMTP relay, voice, WhatsApp, or RCS are required, because those capabilities are absent. Email cannot be treated as a drop-in managed OTP fallback, and a pending domestic email vendor is not evidence for a domestic compliance claim. Country allowlists, per-account and per-IP attempt limits, resend cooldowns, geographic fencing, and country-cost circuit breakers must be enforced in application logic. SMS templates can be retrieved individually but cannot be treated as having an available list operation. Choose Twilio or Vonage when a callback must drive the login branch immediately; choose AWS when existing IAM and cloud-retention controls are the governing boundary.
I would accept polling for a support portal whose approved decision window can absorb it, because the smaller credential and schema surface reduces operational work without pretending to remove policy work. I would choose Twilio or Vonage when callback latency is the requirement, even though that adds a public endpoint and a second evidence join. For an AWS-centered estate, keeping the record inside its existing IAM and retention controls can outweigh portability. None is a universal winner.
Close the trace at the operator action
The page should link directly to the aging cohort and policy version, not to a generic SMS dashboard. The operator can then distinguish a provider-reported failure from an expired local decision window, pause resends if abuse controls are firing, enable only the approved fallback, and append that intervention to the same evidence stream. A later status update remains an observation; it does not rewrite the earlier decision.
There is a real cost to getting this alert wrong. If the threshold is too eager, ordinary carrier delay pages the on-call and may expose a broader login fallback during suspicious traffic. If it is too lax, support agents remain locked out and the compliance record shows that the system waited beyond its own declared objective. Tune the cohort age against observed production distributions, recalculate worker capacity after traffic or retry-policy changes, and require error-budget review before automating a wider fallback.
Quiet dashboards are not evidence.
Neither are successful HTTP responses.
Top comments (0)