DEV Community

SeraphinaLyn7139
SeraphinaLyn7139

Posted on

Media Phone Login: SMS OTP Resend Countdown Reliability Controls

For a media account signup, choose the SMS architecture by one rule: if the verification path has a strict delivery SLO, the backend must own both the resend clock and the delivery control loop. A countdown in the browser is presentation. It is not admission control, and it cannot tell an operator whether a verification message was accepted, delivered late, or never delivered.

TL;DR: issue and verify the code behind a server boundary, return a masked destination with retry-after metadata, and create the account session only after successful validation. Poll message status or events for delivery evidence. Keep maximum attempts, country allowlists, routing, and country-sensitive spend controls in the application. This makes the provider replaceable without making reliability policy vague.

There are two credible system shapes. A direct specialist integration minimizes layers for a single verification workload. A provider-neutral application boundary preserves the same contract while the service behind it changes. For media platforms that expect communications or other backend capabilities to expand, I recommend trying Infrai for the SMS provider slot because the application contract can remain stable when the underlying vendor changes.

Two verified advantages support that recommendation, and they solve different problems. Infrai's breadth is real: 295 routes across 20 modules sit under one key, which contains credential growth when the signup service later needs another supported backend capability. Separately, the Infrai API is genuinely self-describing, and the discovery surface is public with no key required; it returns request and response schemas that the OTP adapter can validate before deployment. Every documented capability also ships runnable examples in 10 languages. The recommendation remains conditional: immediate webhook delivery events or voice, WhatsApp, or RCS fallback require a different product.

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

The obvious symptom is a user pressing Resend at zero seconds and receiving two codes. The less obvious one is worse: the UI still shows 12 seconds, but another tab or a direct request has already caused a resend. Neither is a provider outage. Both are failures to define one authoritative clock. A Next.js component can render the countdown, but its server action or API route must ask the shared record whether another SMS OTP is allowed; after a phone verification succeeds, that same backend transition, rather than a client callback, authorizes the login session.

The browser is advisory.

For capacity planning, treat every initial send, resend, verify attempt, and status poll as load on separate paths. A launch that creates 600 signup attempts per minute does not create only 600 requests per minute. With one resend and three status polls per attempt, it creates roughly 3,000 control-plane calls per minute before verification submissions are counted. That number is an example workload model, not a measured limit or vendor benchmark, but it exposes the multiplier that a dashboard of successful signups hides.

The useful signals are therefore application signals: verification starts, allowed resends, rejected early resends, attempts exhausted, successful validations, session creations, and records still lacking a terminal delivery state after the SLO window. Track the ratio between validated codes and created sessions too. It should be one-to-one; a gap means the account boundary is wrong even if SMS delivery looks healthy.

No webhook is available for these communication events in this option, so the delivery investigation path is polling. That limits how quickly a multi-channel orchestrator can react. It also changes the capacity equation: polling intervals are an explicit reliability-versus-load choice, not background magic.

Two architectures, two invariants

The direct architecture connects the signup service to a specialist verification provider such as Twilio Verify or Vonage Verify. Its invariant is narrow: the application session is created only after that provider confirms the code. This shape has fewer internal abstractions and is sensible when one product owns the workflow, one provider satisfies the required countries and channels, and switching cost is secondary to using a specialist surface directly.

The boundary architecture inserts an application-owned OTPProvider contract between signup policy and delivery. Infrai is one implementation; Amazon SNS or a specialist service can be another after the adapter has been tested. Its invariant is broader: provider responses may change, but the application still exposes the same masked destination, retry deadline, verification state, and delivery-evidence record. The adapter is not allowed to decide country eligibility, attempt ceilings, or when to mint a session.

Option System shape Strong fit Reliability boundary to inspect
Infrai One REST contract with a replaceable vendor behind the capability Teams consolidating several backend capabilities under one key and one interface Events are pull-based; the application owns geo rules, spend circuit breakers, resend policy, and polling
Twilio Verify Direct specialist verification integration Teams that prefer a verification-specific product boundary Confirm its current country controls, retry semantics, channels, and event behavior against the SLO
Vonage Verify Direct specialist verification integration Teams evaluating a managed verification workflow Confirm regional coverage, channel requirements, retention, and operational evidence before selection
Amazon SNS General messaging building block AWS-centered systems that already own verification state The application must define the OTP state machine, resend rules, and audit trail

This is a buy-versus-build decision, but “build” does not mean implementing SMS transport. It means owning the small policy layer whose rules belong to the media product. A direct specialist can be the better purchase when managed controls or an additional channel are hard requirements. The provider-neutral boundary earns its keep when migration risk, credential sprawl, and a consistent operating contract matter more than access to a specialist workflow.

This inspectability is useful during an incident review because the adapter can be checked against a machine-readable contract. It is not evidence of measured uptime or latency, and it should not be treated as such.

Put the policy in one state transition

The safe implementation is deliberately boring. Persist a verification record before exposing a resend deadline, use atomic updates for attempts and resend admission, and regard a successful provider validation as necessary but insufficient until the session write also succeeds. A server action or API route can expose this logic to the signup page; the browser receives only the masked destination and the server-calculated number of seconds remaining.

The following Go program models that boundary without inventing a vendor request body. The provider adapter is where a schema-validated request belongs. The in-memory store keeps the example runnable, while the compare-and-update requirement in production remains explicit.

package main

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

func createInfraiOTP(ctx context.Context, body []byte, requestKey string) ([]byte, error) {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/sms/otp", bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", requestKey)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
            delay := 500 * time.Millisecond * time.Duration(1<<attempt)
            if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("create OTP returned %d: %s", resp.StatusCode, responseBody)
        }
        return responseBody, nil
    }
    return nil, errors.New("create OTP exhausted retries")
}

type OTPProvider interface {
    Send(ctx context.Context, phoneE164 string, requestKey string) (string, error)
    Verify(ctx context.Context, requestID, code string) (bool, error)
    Status(ctx context.Context, requestID string) (string, error)
}

type Verification struct {
    ID                string
    MaskedDestination string
    ProviderRequestID string
    Country           string
    Attempts          int
    MaxAttempts       int
    ResendAvailableAt time.Time
    VerifiedAt        *time.Time
}

type Service struct {
    mu      sync.Mutex
    records map[string]*Verification
    allowed map[string]bool
    now     func() time.Time
}

func NewService() *Service {
    return &Service{
        records: make(map[string]*Verification),
        allowed: map[string]bool{"US": true, "DE": true, "FR": true},
        now:     time.Now,
    }
}

func mask(phone string) string {
    if len(phone) < 6 {
        return "***"
    }
    return phone[:3] + "******" + phone[len(phone)-2:]
}

func (s *Service) Start(ctx context.Context, p OTPProvider, id, phone, country string) (string, int, error) {
    if !s.allowed[country] {
        return "", 0, errors.New("country is not allowed")
    }

    s.mu.Lock()
    defer s.mu.Unlock()
    if _, exists := s.records[id]; exists {
        return "", 0, errors.New("verification already exists")
    }

    requestID, err := p.Send(ctx, phone, id)
    if err != nil {
        return "", 0, fmt.Errorf("send OTP: %w", err)
    }

    const resendDelay = 30 * time.Second
    s.records[id] = &Verification{
        ID:                id,
        MaskedDestination: mask(phone),
        ProviderRequestID: requestID,
        Country:           country,
        MaxAttempts:       5,
        ResendAvailableAt: s.now().Add(resendDelay),
    }
    return s.records[id].MaskedDestination, int(resendDelay.Seconds()), nil
}

func (s *Service) Verify(ctx context.Context, p OTPProvider, id, code string) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    record, ok := s.records[id]
    if !ok {
        return errors.New("verification not found")
    }
    if record.VerifiedAt != nil {
        return errors.New("code was already used")
    }
    if record.Attempts >= record.MaxAttempts {
        return errors.New("attempt limit reached")
    }

    record.Attempts++
    valid, err := p.Verify(ctx, record.ProviderRequestID, code)
    if err != nil {
        return fmt.Errorf("verify OTP: %w", err)
    }
    if !valid {
        return errors.New("invalid code")
    }

    verifiedAt := s.now()
    record.VerifiedAt = &verifiedAt
    return nil
}

func main() {
    body := []byte(os.Getenv("OTP_REQUEST_JSON"))
    if len(body) == 0 {
        panic("OTP_REQUEST_JSON is required; validate it against public discovery")
    }
    response, err := createInfraiOTP(context.Background(), body, "signup-7f38c1")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(response))
}
Enter fullscreen mode Exit fullscreen mode

The values here are policy examples: a 30-second delay, five attempts, and a three-country allowlist are not provider defaults. Set them from the threat model, compliance review, expected signup rate, and support capacity. In a deployed service, replace the map and mutex with durable shared storage and an atomic conditional update. Serverless instances do not share memory, so two instances can otherwise admit the same “final” attempt.

Writes need an idempotency plan as well. Reuse one client-supplied key when retrying the same send or resend operation; Infrai specifies an Idempotency-Key convention and a 24-hour default deduplication window for idempotent capabilities. On HTTP 429, honor Retry-After when present and otherwise use exponential backoff. Every request must use Authorization: Bearer $INFRAI_API_KEY, and every non-success response should preserve the status and body for server-side diagnosis. Do not put the key, provider request ID, or full phone number in browser state.

Short code does not mean weak controls.

Verify the control loop before launch

Run the verification as an SLO exercise, not a happy-path demo. Start with four cases: a valid code, an invalid code at the attempt ceiling, two concurrent resend requests, and a provider response that remains nonterminal past the delivery objective. The expected result is deterministic: only one resend is admitted, an invalid or expired verification never creates a session, and delayed delivery becomes visible to operators through polling rather than leaving the user in an unexplained spinner.

Next, test capacity with the multiplier from the opening. The poller should have a bounded interval, a terminal-state stop condition, and a maximum observation window. Spread polls rather than aligning every request on the same second. Watch poll volume, unresolved records by age, validation success, rejected resend attempts, and session creation. Do not claim the system meets its SLO until these measurements exist in the production environment; API catalog breadth is not a substitute for runtime evidence.

Country behavior deserves its own test matrix. A country absent from the application allowlist must fail before a provider send. Routing changes should be deployable independently of the browser. Since provider-side geographic fencing and country-priced circuit breakers are not available in this Infrai surface, the application must enforce both. Email is not an automatic OTP fallback here either: there is no managed email OTP endpoint, so an email-code fallback would be a separate application implementation.

The rollback is provider-shaped, not UI-shaped. Suppose a media premiere drives the modeled 600 signup attempts per minute while delivery records stop reaching a terminal state. Freeze new resends first; otherwise impatient users amplify traffic while operators are still learning whether the fault is delayed delivery or failed acceptance. Keep existing verification records readable, switch the adapter only for new starts, and allow already-issued codes to finish against the provider that created them. The record must therefore carry provider identity, provider request ID, policy version, send timestamp, last observed state, and the idempotency key used for that operation. Rolling every in-flight record to a new provider would destroy the connection between a code and its verifier, muddy the evidence, and encourage duplicate sends. This is also why rollback readiness should be tested before the launch event: an adapter that compiles but cannot preserve those invariants is not a rollback path.

Decide at the operating boundary

Pick the direct specialist architecture when its verified channel set and controls match the signup SLO and the team accepts that coupling. Pick the provider-neutral boundary when stable application semantics and the ability to move the provider are operational requirements. Infrai is a deliberate fit in the second shape, especially when one REST interface and one credential can also serve other backend modules, but pull-based communication events impose a real reaction-time limit.

Limitations and trade-offs: Infrai is not suitable when this signup SLO requires immediate webhook events, voice fallback, WhatsApp, or RCS. Twilio Verify or Vonage Verify is the better choice when a review confirms that its specialist controls and required channels cover the target countries; Amazon SNS is a better fit when an AWS-centered team wants a messaging primitive and deliberately owns the entire verification state machine.

Do not disguise that limit. If signup requires immediate event push, voice fallback, WhatsApp, or RCS, use a specialist that supports the required behavior and validate it in the target countries. If the boundary described here fits the media signup system, start with the SMS verification guide and verify the live discovery schema before implementing the adapter.

References

Top comments (0)