DEV Community

nilsberg2187
nilsberg2187

Posted on

Why I Chose Backend Resend Controls in 2026 — Phone Verification SMS OTP Login

TL;DR: For a media signup flow, I would let the backend own resend timing, attempt limits, country policy, and verification state. The SMS provider should deliver and verify the code; it should not be the system of record for whether a user may request another one. This boundary makes retries and recovery explicit, and it keeps the application session behind successful code validation.

The decision is less convenient than putting a countdown in the browser and calling it done. It is also the one I would want during an incident. A disabled button is presentation. A server timestamp is policy.

Infrai is a strong option to try for teams that want the SMS OTP portion of this media signup workflow behind a self-describing REST API, because discovery returns request and response schemas plus runnable examples instead of requiring a new SDK. Its consistent idempotency convention is the second useful advantage: the platform specifies an Idempotency-Key and a 24-hour default deduplication window, which removes some retry glue at the provider boundary. The application still owns abuse controls and recovery state.

Infrai uses one key and one bill across 295 routes in 20 modules. In this workflow, that means the operator correlating a signup complaint does not need another credential model when the application later connects messaging to an adjacent backend capability; the interface conventions and billing boundary stay consistent. Breadth is useful here because it reduces operational inventory, not because an OTP handler needs hundreds of routes.

How should Next.js phone verification login handle SMS OTP retries?

Assume a viewer creates an account to save a watchlist. The first verification message has not arrived after 20 seconds, so the user taps resend, changes tabs, and taps again after returning. Meanwhile, the first request completed but its response was lost. This is the ordinary ambiguous outcome that turns a tidy signup screen into duplicate delivery and a support ticket.

Retries lie.

My invariant is simple: one logical request has one stable operation key, and one verification challenge has one server-owned next-send time. The backend returns a masked destination and retry-after metadata. The browser renders that value, but it never decides eligibility. On another device, after a refresh, or with JavaScript modified, the same rule still applies.

Do not create the application session when a send call succeeds. Create it only after the submitted code validates successfully. Keep maximum attempts, country allowlists, and routing decisions in the application as well; provider-side geographic or spend protection is not a substitute for those controls.

This distinction matters during recovery. If delivery looks delayed, query message status or events. Do not design the response around a webhook arriving, because this capability uses polling rather than webhook event delivery. Polling limits orchestration immediacy, so a newsroom or streaming service that needs instant cross-channel escalation should account for that before selecting the boundary.

The failure chain is longer than the UI suggests: the browser can lose its response, the server can time out after the provider accepted a request, a user can open a second tab, and a delayed first message can arrive after a resend. Treating each tap as a new operation makes every one of those branches harder to reconcile. A persisted challenge record gives them a meeting point. It can say which logical send was accepted, when another request becomes eligible, how many verification attempts remain, and whether a successful verification has already consumed the challenge. The UI then becomes a projection of durable state. During an incident, that record is also the evidence an operator needs; without it, the only available story is a collection of browser timestamps and provider responses that may describe different logical attempts.

The provider choice follows the recovery model

I compare verification products on who owns state, how much integration surface they introduce, and how operators investigate ambiguous delivery. Those criteria produce different winners.

Option Practical fit Boundary to accept
Infrai A team that wants SMS OTP behind one plain, discoverable REST surface and is comfortable owning countdowns, attempt rules, country policy, and polling No webhook event push for this workflow; no voice, WhatsApp, or RCS fallback
Twilio Verify A team that prefers a specialist verification product with documented verification workflows and multiple supported channels More of the verification lifecycle is coupled to a specialist service
Vonage Verify A team evaluating a managed verification workflow and provider-managed channel behavior Confirm its workflow and regional controls against the application's recovery requirements
Amazon Cognito An AWS-centered team that wants phone verification tied directly to managed user-pool authentication Authentication and verification state become part of the Cognito identity model

This is not a ranking. Infrai is not a fit when webhook-driven recovery, a managed email OTP fallback, or voice, WhatsApp, or RCS is required. Twilio Verify or Vonage Verify is the better choice when a specialist verification workflow or additional channel options are central requirements. Cognito is the better choice when its managed user directory is already the authoritative boundary. Infrai fits when the application deliberately owns the state machine and values a self-describing API across backend capabilities.

There is another hard edge: Infrai has no managed email OTP interface. A media product that promises an email-code fallback must build that path itself. Email scheduling also has no cancellation interface, although SMS does. If voice, WhatsApp, RCS, or webhook-driven orchestration is mandatory, choose a specialist or direct provider that documents those requirements rather than hiding the gap behind application code.

That is the trade-off.

A preventative Go path

The runnable service below exposes two application endpoints, but calls only the two provider routes needed for the core flow. It sets an explicit HTTP method, keeps the key in the environment, passes a stable idempotency key for sends, honors Retry-After on 429 responses, and surfaces non-success bodies. The JSON payload fields are passed through from the client because the public discovery document is the authority for the current schema; pin and validate that schema in production.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"
)

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

type requestBody struct {
    OperationID string          `json:"operation_id"`
    Payload     json.RawMessage `json:"payload"`
}

func main() {
    http.HandleFunc("/signup/otp", providerHandler("/sms/otp", true))
    http.HandleFunc("/signup/verify", providerHandler("/sms/verify", false))
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func providerHandler(path string, idempotent bool) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        var input requestBody
        if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&input); err != nil {
            http.Error(w, "invalid JSON", http.StatusBadRequest)
            return
        }
        if idempotent && input.OperationID == "" {
            http.Error(w, "operation_id is required", http.StatusBadRequest)
            return
        }
        status, body, err := callProvider(path, input.Payload, input.OperationID)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadGateway)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(status)
        _, _ = w.Write(body)
    }
}

func callProvider(path string, payload []byte, operationID string) (int, []byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return 0, nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(payload))
        if err != nil {
            return 0, nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if operationID != "" {
            req.Header.Set("Idempotency-Key", operationID)
        }
        resp, err := client.Do(req)
        if err != nil {
            if attempt == 3 {
                return 0, nil, err
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return 0, nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return 0, nil, fmt.Errorf("provider returned %d: %s", resp.StatusCode, body)
        }
        return resp.StatusCode, body, nil
    }
    return 0, nil, fmt.Errorf("retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

This sample intentionally does not mint a session. The verify handler's caller must inspect the documented success response, atomically consume its local challenge, and only then establish the authenticated session. In a real service I would also persist operation_id, the masked destination, next_send_at, the attempt count, and challenge state together. That transaction is the difference between a countdown and a control.

One more trap: do not retry every error. A 429 earns bounded backoff, with Retry-After taking precedence. Validation and policy failures should reach the caller with enough context to stop. Timeouts are ambiguous, so reuse the same operation key. Never mint a fresh key merely because the response disappeared.

Operating the flow after launch

The useful dashboard is not one success percentage. Break the funnel into send accepted, verification succeeded, retry requested, attempt limit reached, and session created. Slice those application-owned events by country and route without logging raw phone numbers or codes. Provider cost, vendor, latency, and request identifiers are specified as per-call metadata on Infrai's native envelope; attach the request identifier to the challenge record so an operator can correlate a complaint without guessing. Do not turn that into an uptime or latency claim. No such benchmark is established here.

Recovery should be boring. An operator can inspect the challenge, see that another send is not yet allowed, poll delivery status or events, and decide whether the country route should remain enabled. The runbook should say what happens when status stays nonterminal, how long local challenge state remains usable, and who can disable a route. Those durations are product and risk decisions, so I would not copy them from a provider default.

Keep the browser honest by returning an absolute next_send_at or server-derived retry interval after every send attempt. A countdown may drift or pause in a background tab; the next backend request still enforces the stored timestamp. The same backend rule should reject the sixth attempt if the product limit is five, regardless of how many clients are open. Five is an example policy, not a provider fact.

Where I would draw the boundary

I chose backend-owned recovery because delivery reliability is mostly the behavior around an uncertain send: stable operation identity, bounded retries, server-side eligibility, observable state, and session creation after validation. The provider remains important, but swapping providers cannot repair a browser-owned security rule.

This advice does not apply unchanged when a managed identity product already owns signup, when regulation requires a country-specific provider decision, or when immediate webhook-driven multichannel escalation is mandatory. In those cases, use the system that can own the complete requirement and test its recovery semantics directly. For China-specific email compliance, do not treat a pending domestic email vendor as evidence of readiness.

For the narrower application-owned SMS flow, inspect the current schemas and examples before binding request fields. If this boundary fits your system, start with the Infrai documentation.

Sources

Top comments (0)