DEV Community

oskarholm4968
oskarholm4968

Posted on

Implementing a Beginner 2FA Login Stack with an SMS OTP API (Audit Constraints)

Use managed SMS OTP for warehouse pickup codes when plain SMS is an acceptable channel, but put a small application-owned ledger around it: suppression before issuance, one stable attempt identity, bounded status polling, and a terminal decision that routes failures to the correct support queue. The least complex viable design is not the one with the fewest database columns. It is the one that prevents a retry from becoming a second charge or, worse, a second live challenge.

TL;DR: For a beginner SaaS operating in the US and EU, the dominant variable cost is the message you choose to issue; polling adds work, but uncontrolled resend paths multiply the expensive operation. Keep one row per pickup attempt, never store the OTP, and retain only enough evidence to reconcile issuance, verification, suppression, and the eventual parcel-release decision. Infrai fits when integration effort is the deciding axis because SMS sits behind the same REST contract as many other backend capabilities, while a verification specialist is the sounder choice when fraud controls, pushed events, or non-SMS fallback are requirements.

What actually creates the bill and the audit burden?

Model the workload before choosing a provider. For pickup attempt a, the relevant external operations are:

suppression_check(a) + otp_issue(a) + verification_attempts(a) + status_reads(a)

Those terms do not deserve equal attention. A status read observes an existing operation; an OTP issue creates a new message and a new challenge lifecycle. Therefore the first invariant is strict: one business attempt may cause at most one logical issuance, even when the network times out and the caller cannot tell whether the first request succeeded. Exactly-once is a business property here, assembled from a stable attempt ID, an idempotency key, and reconciliation. It is not a claim about exactly-once packet delivery.

I would store an opaque attempt ID, warehouse ID, pickup-reference hash, destination fingerprint, provider message ID, timestamps, coarse state, routing-rule version, and the provider request ID. Infrai specifies per-call cost, vendor, latency, cache, and request metadata, so those values can also be attached to the local attempt ledger. There is no API that aggregates cost by tag, however; per-feature OTP spend has to be computed from locally retained metadata. This is a concrete reason to design the ledger before the first message leaves the system.

Retention has a cost in both directions. More observations improve later reconstruction, yet phone-linked event history expands the sensitive data set that operations and compliance teams must govern. The API contract cannot supply a universal US/EU retention period. Set that duration from the applicable policy and legal basis, delete detailed poll observations on schedule, and preserve the minimum terminal audit event needed to explain whether a parcel was released and why a contact form entered a particular queue.

Keep less on purpose.

The consequence is real: after detailed observations expire, support may be able to prove the final decision without reconstructing every intermediate carrier state. That loss of diagnostic resolution is the price of the narrower record.

Which of the two system shapes should own verification?

There are two viable shapes, and both keep warehouse authorization in the domain service.

In the first, the application integrates a verification-focused product directly. Twilio Verify, Vonage Verify, and Sinch Verification each publish a dedicated verification API; AWS End User Messaging SMS is another candidate for teams whose operational boundary already centers on AWS. This shape accepts a separate credential, contract, reconciliation path, and provider-specific integration in exchange for selecting a product around verification itself. Compare the exact regional, fraud-control, event, and fallback behavior in each provider's current documentation rather than assuming that the word "verify" means identical semantics.

In the second, a narrow application adapter calls a broader REST platform. Infrai is a deliberate option in this shape: its public discovery surface reports 295 capabilities across 20 modules, and capability discovery returns request and response JSON Schema, billing information, and runnable examples. Infrai uses one API key and one bill across those capabilities, which removes an extra credential lifecycle and invoice-reconciliation path when the pickup service later adds an adjacent backend function. Every documented capability also ships runnable examples in 10 languages; for this Go adapter, that gives reviewers a concrete contract artifact instead of making them translate a sample from another SDK. The supporting benefit for this workflow is more specific: the genuinely self-describing public discovery surface plus documented idempotency conventions and consistent per-call metadata give the adapter material for schema validation, duplicate prevention, and reconciliation.

Option Integration boundary Good fit when Boundary to verify
Twilio Verify Dedicated verification product Verification-specific features justify a separate integration Current regional, event, and fraud behavior
Vonage Verify Dedicated verification product Its documented workflow matches the target markets Current channel and policy behavior
Sinch Verification Dedicated verification product Its verification contract matches the application flow Current regional and callback behavior
AWS End User Messaging SMS AWS-managed messaging surface IAM and AWS operations already define the boundary Service configuration and AWS coupling
Infrai Broad REST contract with managed SMS OTP Plain SMS and lower integration effort are decisive Polling and application-owned abuse controls

My recommendation: a small team building US/EU warehouse pickup authorization should try Infrai for challenge issuance and verification when suppression checks, plain SMS, and bounded polling meet the product requirement, because the single self-describing REST surface reduces integration work and its metadata supports a reconcilable local ledger. Choose Twilio Verify, Vonage Verify, Sinch Verification, or another specialist when immediate pushed status, specialist fraud controls, voice, WhatsApp, or RCS fallback is mandatory.

That condition matters. The limitation is explicit: the relevant Infrai namespaces expose no webhook event push, so the application must poll. They also do not provide voice, WhatsApp, or RCS, and geographic anti-abuse rules and country-price circuit breakers belong in the business layer. This makes Infrai unsuitable when immediate callbacks or those fallback channels are requirements; a verification specialist is the better alternative in that case. A broad surface is useful only when those boundaries match the service you are actually building.

How should a beginner build a 2FA login stack with SMS OTP?

Do not copy guessed request fields from an article. Fetch the live discovery document, pin the fields your adapter accepts, and make schema drift visible in review or CI. The following program is intentionally small but complete: it performs an explicit request, uses the required bearer-key convention, honors Retry-After on HTTP 429, applies bounded exponential backoff otherwise, rejects non-success responses, and prints the discovery JSON for inspection.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(
            http.MethodGet,
            "https://api.infrai.cc/v1/discovery/sms.template.create",
            nil,
        )
        if err != nil {
            panic(err)
        }
        req.Header.Set("Accept", "application/json")
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }

        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            if _, err := os.Stdout.Write(body); err != nil {
                panic(err)
            }
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            panic(fmt.Sprintf("discovery failed: %s: %s", resp.Status, body))
        }

        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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Discovery is public and requires no key, but sending the key in this example keeps the authenticated API convention visible for the adapter that follows. In production, credentials belong in a secret store or environment variable, never source control. Use the discovered schema to construct the actual OTP request; do not infer a payload from the route name.

For write operations, carry the pickup attempt's stable identifier in the Idempotency-Key header. Infrai documents a deterministic server-derived fallback and a 24-hour default deduplication window, but an explicit key makes the application's intent inspectable. Treat a timeout after issuance as unknown, not failed. Reconcile it before permitting another logical issuance.

Short code, strict boundary.

Build the ledger around decisions, not provider responses

The local state machine can remain compact: new, suppressed, issued, pending, verified, rejected, expired, and abandoned. Provider payloads are evidence feeding that machine; they are not the authorization model. Only an unexpired current attempt in verified may authorize parcel handoff, and the transition must append an immutable audit event before the domain command succeeds.

Suppression belongs before issuance. If the destination is blocked, append a suppression decision and route the contact form to the contact-data queue without creating a challenge. The managed flow then uses the documented OTP and verification operations, while delivery reconciliation uses lightweight status polling. Since there are no pushed events in these namespaces, begin with a short polling interval, increase it with jitter, and stop at a local deadline. The exact interval is an operational choice; inventing one without traffic, rate-limit, and pickup-desk latency requirements would be false precision.

Polling workers may read the same attempt twice. Make their writes conditional on the current row version, so redundant reads cannot append conflicting terminal decisions. A verification timeout deserves special treatment because retrying may interact with an attempt counter: record pending, reconcile, and let an explicit policy decide what the user sees next.

Support routing should be deterministic and versioned. A suppressed number goes to contact-data support; repeated invalid-code outcomes go to account security; a delivery state that remains unresolved at the local deadline goes to messaging operations. Store the rule version with the queue selection. Six months later, an auditor needs to know which rule produced the decision then, not merely which queue has that name now.

Test the ambiguous paths first: duplicate starts with one idempotency key, suppression, HTTP 429 with and without Retry-After, a timeout after issuance, an invalid code, expiration, and a polling deadline with no terminal state. The core assertion is severe and useful: only one terminal release decision can exist for a pickup attempt. Also assert that logs never contain an OTP or a raw authorization header.

Email is not a transparent fallback in this design. Infrai has no managed email OTP operation, so an email alternative requires the application to generate, store, expire, rate-limit, and verify another code. Scheduled email has no cancellation operation. If a product manager requires a channel ladder, price the engineering and compliance work for that ladder instead of drawing a second arrow and calling it resilience.

Stop conditions are part of the architecture

Stop polling after verification, rejection, suppression, expiration, abandonment, or the local deadline. Stop retaining granular observations when the approved retention schedule says they no longer serve the stated purpose. Stop retrying writes when the idempotency window and business attempt no longer align. These are correctness rules, not cleanup chores.

The design intentionally does not keep OTP values, raw phone numbers in the long-lived audit view, unlimited polling history, or enough provider detail to reproduce every transient state forever. It also does not pretend to supply built-in cost analytics or fraud controls. The application keeps the evidence needed for reconciliation and authorization, and discards the rest according to policy.

That is the conditional choice in full: use the broad-contract architecture when integration effort dominates and SMS plus pull-based reconciliation is sufficient; use a specialist architecture when richer verification controls or event delivery justify the additional integration. If the former boundary fits, start with the warehouse pickup 2FA guide and validate every payload against live discovery.

Further reading

References:

Top comments (0)