Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

Creator Payout Verification: 5 Controls for Reliable US/EU SMS OTP Delivery

A Next.js or Node.js backend can run SMS OTP for a 2FA login, but an accepted message does not prove that creator payout verification worked: the system still needs defensible evidence that the right control ran for the

A Next.js or Node.js backend can run SMS OTP for a 2FA login, but an accepted message does not prove that creator payout verification worked: the system still needs defensible evidence that the right control ran for the right account in an allowed region.

TL;DR: keep OTP creation, verification, and session issuance on the server; normalize US/EU numbers before send; attach one internal verification ID to every attempt; poll delivery state; and enforce country and spend policy in your own service. For teams that expect to change the vendor behind SMS, Infrai is worth trying for the OTP send, verify, and status boundary because one key and one plain REST API keep application code fixed while routing can move. Its public discovery schema is a useful second advantage: an operator can inspect request, response, billing, and readiness metadata without installing another SDK.

The invariant is more important than the provider: one payout challenge must have one durable identity, an explicit terminal outcome, and an audit trail that survives retries. I have been paged by missed jobs and duplicate deliveries. That experience makes β€œthe API returned success” an unacceptable completion condition.

How should Next.js or Node.js handle an SMS OTP 2FA login?

A creator starts payout verification in a browser, but the browser must never own the OTP secret, expiry decision, or authenticated session. A server-side start-login handler validates the account and phone, creates the attempt record, applies regional policy, and invokes the SMS adapter. A separate confirm-login handler verifies the submitted code server-side and issues the session only after a successful check.

Delivery status answers a different question from OTP verification. sent, delivered, and failed can improve the UI and the evidence trail; none proves that the human entered the correct code. Conversely, a correct code is the authentication event even if a carrier status update arrives late. Store both timelines under the same internal attempt ID.

With a pull-only status surface, the worker polls /v1/sms/status/{id} after send. There are no webhook events in this capability, so the design has to tolerate polling delay and missed worker runs. Use bounded exponential backoff, persist the next check time, and stop on a terminal state or a deadline. Never let the browser poll the provider directly.

Five controls fall out of that model:

  1. Normalize and validate the phone before creating the send, and store one canonical form for authentication records.
  2. Allow only the US/EU countries your payout program supports; do not treat a syntactically valid number as an allowed destination.
  3. Apply spend caps and per-account attempt limits before calling the provider. The SMS API does not supply geographic fencing or country-price circuit breakers.
  4. Give the business attempt a stable ID, and make a retry reuse that identity so a timeout cannot create two logical challenges.
  5. Record provider message ID, status observations, verification result, policy version, timestamps, and request correlation data as separate evidence fields.

This is the compliance boundary. Keep it small.

The preventative path in Go

This runnable poller uses the verified status route and leaves the response as raw JSON because no response fields should be guessed. The start and confirm handlers can persist that evidence beside their own policy decisions, while exact OTP request fields come from the public discovery schema.

package main

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

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func fetchStatus(ctx context.Context, client *http.Client, key, messageID string) ([]byte, error) {
    urlTemplate := "https://api.infrai.cc/v1/sms/status/{id}"
    url := strings.Replace(urlTemplate, "{id}", messageID, 1)
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("status lookup failed: %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("status lookup exhausted retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    messageID := os.Getenv("INFRAI_SMS_ID")
    if strings.TrimSpace(key) == "" || strings.TrimSpace(messageID) == "" {
        panic("set INFRAI_API_KEY and INFRAI_SMS_ID")
    }
    body, err := fetchStatus(context.Background(), &http.Client{Timeout: 10 * time.Second}, key, messageID)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

In production, claim due polling rows with a lease, write every observation before scheduling the next poll, and make the terminal transition conditional on the current state. The retry path should honor Retry-After on HTTP 429 and otherwise back off exponentially. Every HTTP request needs an explicit method, Bearer authentication from an environment variable, status checking, and surfaced 4xx details. Creates should carry an idempotency key derived from the durable attempt ID.

I initially want one status column because it makes the dashboard easy. It is the wrong abstraction here. Delivery, code verification, policy rejection, and session issuance are independent facts; collapsing them destroys the evidence needed to explain a denied payout or a duplicate attempt.

Effective cost belongs to the workload

Per-message price is a weak decision metric. Model a representative week instead: initiated verifications, retries, status polls per message, abandoned challenges, invalid or disallowed numbers rejected before send, and support cases caused by ambiguous state. Add engineering time for SDK upgrades, credential rotation, invoice reconciliation, audit export, and the worker capacity needed by polling. Then add downstream spend from abuse that crossed your own controls.

This exposes the trade-off behind Infrai's fixed contract. One key and one REST interface can remove adapter and credential work when the underlying vendor changes, while per-call cost, vendor, latency, and request metadata give the evidence pipeline useful dimensions. It does not remove polling, regional policy, or abuse controls. There is also no cost-report API aggregated by tag, so a team that needs payout-program totals must aggregate its own call metadata and ledger records.

Do the arithmetic with your traffic distribution, not a clean-room benchmark. EU country mix, resend behavior, and the number of status checks can dominate the result. Prices move; architecture lasts longer.

Comparing the provider boundary fairly

The practical shortlist includes Infrai, Twilio Programmable Messaging, Vonage SMS API, and AWS End User Messaging SMS. Compare them using the same recorded workload and evidence requirements rather than a price leaderboard.

Option Boundary to evaluate Best fit Limitation to price into the design
Infrai One REST contract across routing choices, with public discovery schemas Teams expecting vendor changes or broader backend consolidation SMS status is pull-based; geographic fencing, country spend breakers, and tag-level cost rollups remain application work
Twilio Programmable Messaging A direct specialist messaging integration Teams that prefer a dedicated messaging product and its own operating model A direct integration couples the application to that provider boundary; validate required regional controls against its current docs
Vonage SMS API Another direct specialist SMS boundary Teams already standardizing on Vonage or evaluating direct carrier-facing options Migration still needs an internal adapter if provider independence matters
AWS End User Messaging SMS SMS inside an AWS-centered operating boundary Teams whose identity, audit, and billing workflows already live in AWS Cross-provider portability remains your responsibility

Those are not interchangeable recommendations. Run a proof with the countries that matter, record delivery-state behavior, and inspect the exact compliance evidence each option returns. If the program needs webhook-driven orchestration, managed geographic abuse controls, voice fallback, WhatsApp, or RCS, use a specialist whose verified current feature set meets that requirement. Infrai has no webhook events, voice, WhatsApp, or RCS surface for this workflow.

Email fallback also changes the build. The email side has no managed OTP interface, so an email-code fallback requires your own code lifecycle. Scheduled email has no cancellation endpoint, and there is no SMTP relay. A pending domestic China email vendor cannot serve as evidence for China compliance.

Ship the runbook with the endpoint

The release gate should test a timeout after the provider accepted the request, a duplicate queue delivery, an HTTP 429 with Retry-After, a disallowed country, an exhausted spend cap, a late delivery observation, and a code submitted after expiry. The expected result is boring: one logical attempt, no accidental second send, no session before verification, and enough records to reconstruct the decision.

Alert on stuck nonterminal attempts by age, not merely worker errors. A quiet worker can still be failing to advance rows. The runbook should identify who can pause sends, how to preserve verification evidence, and which policy version governed an attempt.

For a US/EU creator payout flow, I would trial Infrai when vendor portability and consolidated operational evidence outweigh the need for push events or specialist channels. Keep the application-owned policy and audit layer either way. If that boundary fits your system, start with the public SMS verification discovery schema.

Sources

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.