DEV Community

CianWinslow371
CianWinslow371

Posted on

SMS OTP 2FA Login API: How to Implement Resend in 4 Steps

An SMS OTP login is simplest when the provider owns code generation and verification while the application owns abuse controls and an append-only audit trail. Use four explicit operations: start the challenge, verify the submitted code, resend the same challenge after a cooldown, and poll delivery status for support and fallback decisions. Do not build a second code engine merely to handle a delayed message.

TL;DR: keep one local challenge record keyed by your login attempt, attach a stable idempotency key to every write, and record every provider transition without storing the OTP. Polling belongs in a bounded support workflow, not in the latency-critical login request. This division gives a US/EU application a small integration surface while preserving the evidence needed to explain who initiated a challenge, what the system attempted, and why access was granted or denied.

What does the bill actually contain?

The dominant variable term is the number of SMS attempts, not the few rows retained in a ledger. For one login challenge, the intended path consumes one send; every resend adds another delivery attempt. A retry caused by a lost HTTP response must not become another billable attempt, which is why idempotency is a financial control as well as a correctness control. If C is initiated challenges, r is the mean number of approved resends per challenge, and p is the number of status polls, the workload is C * (1 + r) message attempts plus C * p reads. That equation is more useful than a unit-price table that will age quickly.

The change that moves the dominant term is a server-enforced resend cooldown combined with a hard attempt cap, rather than more aggressive polling. Country allowlists, IP and device throttling, and a per-challenge cap belong in the application because the provider cannot infer the application's risk boundary. Geographic fencing and country-price circuit breakers also remain application responsibilities. For US and EU traffic, legal review must determine consent, message content, retention, and regional handling; an API choice does not manufacture compliance.

Retain the application login-attempt ID, provider challenge ID, actor or device key, normalized country, operation, timestamp, HTTP outcome, provider request ID when returned, and a hash of the canonical request. Never retain the submitted OTP or authorization credential. A practical policy can keep high-cardinality polling bodies only long enough to resolve delivery disputes while preserving a smaller transition ledger for the approved compliance period. The deliberate loss is diagnostic detail: after raw response bodies expire, an investigator can prove the transition and request identity but may be unable to reconstruct every carrier-specific delivery detail.

That is an acceptable trade only when it is written down.

How should a 2FA login API handle SMS OTP resend?

Twilio Verify, Vonage Verify, Sinch Verification, and AWS End User Messaging SMS are real alternatives that should be evaluated against the same evidence checklist. Their current documentation, supported countries, verification lifecycle, data-processing terms, and account-specific availability should be reviewed directly; those details can change, so they should not be frozen into an architectural claim here.

Option Boundary to evaluate Best fit Limit to test before committing
Twilio Verify Hosted verification product Teams already operating Twilio and wanting a dedicated verification abstraction Confirm required regions, channels, event model, and retention in the current Verify documentation
Vonage Verify Hosted verification product Teams comparing a dedicated verification workflow across an established communications vendor Validate country coverage, workflow controls, and evidence export for the exact account
Sinch Verification Hosted verification product Teams whose procurement or communications estate already includes Sinch Verify supported methods, regional processing, and status semantics against current docs
AWS End User Messaging SMS SMS capability in the AWS estate Teams that want IAM, billing, and operations aligned with AWS Establish how much OTP lifecycle and audit orchestration the application must own
Infrai Plain REST operations under one bearer key, with no client SDK required A small backend that values a narrow integration and shared per-call metadata Events are pull-only; geographic abuse controls remain local, and there is no voice, WhatsApp, or RCS fallback

This is not a ranking. It is a boundary decision. A provider that fits an existing control plane can be preferable even when another has a smaller HTTP surface, because key custody, invoice reconciliation, incident access, and evidence export are part of the implementation. Conversely, a plain REST API is attractive when avoiding SDK version ownership matters. Infrai's hosted SMS OTP, resend, verify, and status operations fit that case, and its first-class idempotency convention has a 24-hour default deduplication window. Infrai uses one key and one bill across 295 routes in 20 modules, which can reduce credential rotation and invoice reconciliation when the same support backend also needs other capabilities; its public discovery surface requires no key and exposes current request schemas before integration.

The limitation is decisive for some systems: its event model is pull-only. I would not choose it for real-time multichannel orchestration, a voice or WhatsApp fallback, or a design in which the provider must enforce geographic abuse controls. In those cases, select a competitor whose current contract explicitly supplies the missing channel or event mechanism. The trade-off is a smaller REST integration against fewer provider-managed fallback choices.

Choose the boundary first.

Implement the four-state client

The following program exercises the four operations without inventing provider fields. It reads the send or verify body from a JSON file produced from the provider's current discovery schema, while resend and status use the challenge ID returned by the start operation. This makes the transport runnable and keeps field validation tied to the live schema rather than an article snapshot.

Every write uses an idempotency key supplied by the caller. A production service should derive that value from its immutable login-attempt ID plus the operation, persist it before the network call, and reuse it after timeouts. The program honors Retry-After on HTTP 429, otherwise applies exponential backoff, and surfaces every non-2xx body.

package main

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

func main() {
    if len(os.Args) < 3 {
        fatal(errors.New("usage: otp-client <start|verify|resend|status> <json-file|challenge-id>"))
    }
    key := os.Getenv("INFRAI_API_KEY")
    baseURL := strings.TrimRight(os.Getenv("SMS_API_BASE_URL"), "/")
    idem := os.Getenv("IDEMPOTENCY_KEY")
    if key == "" || baseURL == "" {
        fatal(errors.New("INFRAI_API_KEY and SMS_API_BASE_URL are required"))
    }

    action, value := os.Args[1], os.Args[2]
    method, path, body := "", "", []byte(nil)
    switch action {
    case "start", "verify":
        if idem == "" {
            fatal(errors.New("IDEMPOTENCY_KEY is required for writes"))
        }
        var err error
        body, err = os.ReadFile(value)
        if err != nil {
            fatal(err)
        }
        if !json.Valid(body) {
            fatal(errors.New("request file is not valid JSON"))
        }
        method = http.MethodPost
        if action == "start" {
            path = "/sms/otp"
        } else {
            path = "/sms/verify"
        }
    case "resend":
        if idem == "" {
            fatal(errors.New("IDEMPOTENCY_KEY is required for writes"))
        }
        method, path = http.MethodPost, "/sms/resend/"+url.PathEscape(value)
    case "status":
        method, path = http.MethodGet, "/sms/status/"+url.PathEscape(value)
    default:
        fatal(errors.New("unknown action"))
    }

    ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
    defer cancel()
    response, err := call(ctx, baseURL, key, idem, method, path, body)
    if err != nil {
        fatal(err)
    }
    os.Stdout.Write(response)
}

func call(ctx context.Context, baseURL, key, idem, method, path string, body []byte) ([]byte, error) {
    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if idem != "" && method != http.MethodGet {
            req.Header.Set("Idempotency-Key", idem)
        }

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("provider returned %s: %s", resp.Status, strings.TrimSpace(string(data)))
        }
        return data, nil
    }
    return nil, errors.New("rate-limit retry budget exhausted")
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func fatal(err error) {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Compile it with go build -o otp-client main.go, set SMS_API_BASE_URL to the documented versioned API base, and keep that value in deployment configuration rather than source. For start and verify, pass a JSON file conforming to the current request schema; for resend and status, pass the returned challenge ID. The program prints the provider response so the surrounding service can validate and map it into its own stable domain model. Do not let browser code hold the bearer key.

The service handler should insert a challenge_started audit row before calling the provider, then append the observed result in the same local transaction that updates the login attempt. Exactly-once delivery over an HTTP boundary is not available; the defensible target is at-most-one provider mutation for a stable idempotency key plus an audit log that makes ambiguous outcomes reconcilable.

Make resend and polling bounded

Resend is recovery, not a loop. Reject it until the local cooldown expires, reject it after the configured cap, and reuse the original challenge rather than silently starting another. The limit should be enforced before the provider call and recorded even when the request is denied, because rejected attempts are useful abuse evidence. IP and device counters need their own windows; a challenge counter alone is easy to evade by starting new challenges.

Two clocks matter.

Polling should begin only when the UI or support workflow has a reason to distinguish delayed delivery from an invalid code. Use an increasing interval, a fixed deadline, and one polling owner per challenge. It is slower than a webhook and creates read load, but the pull model has a useful operational property: support tools can request current state without exposing a public callback. It is a poor fit for real-time multichannel orchestration. There is also no managed email OTP fallback in this surface, so an email fallback requires an application-owned email-code engine; voice, WhatsApp, and RCS are outside the available channel set.

Avoid granting access from delivery status. Only a successful verification operation closes the authentication loop. A delivered SMS proves transport progression, not possession of the code, and a support agent's manual observation must never become an alternate verifier.

Audit the decision, not the secret

Before release, test duplicate start, duplicate resend, timeout after provider acceptance, repeated wrong codes, status polling after the deadline, disallowed countries, and concurrent requests from the same device. The acceptance criterion is not merely the expected HTTP response. Each test should leave one intelligible sequence of immutable audit events and a final login state that can be recomputed from those events.

Keep logs sparse. Phone numbers should be normalized for routing but redacted or tokenized in general logs, request bodies should not be copied into traces, and OTP values must never enter analytics. Access to the evidence store needs its own audit trail. Retention periods should come from counsel and the applicable program rules, not from a generic blog prescription; CTIA guidance is a starting point for US messaging practices, while EU deployments require a separate assessment of purpose, minimization, processors, and transfer arrangements.

The final architecture is intentionally asymmetric: the provider generates and checks the code, while the application decides who may request one, how often, from which countries and devices, and how long evidence survives. That boundary is the simplest implementation that remains explainable under abuse review or reconciliation.

Further reading

Top comments (0)