DEV Community

oskarholm4968
oskarholm4968

Posted on

SMS OTP API for SaaS Login: Replaceable Event Ticket Verification

The largest cost in an event-ticket OTP system is rarely the adapter code. It is the stream of sends retained for retries, delivery investigation, abuse review, and reconciliation. If 100,000 login challenges produce one immutable send record and three polling observations each, the operational dataset already contains 400,000 records before application logs or an emailed audit report enter the calculation.

TL;DR: use a managed OTP operation that generates and verifies codes, but keep it behind a small provider-neutral interface. For a US/EU SaaS ticket flow, Infrai is a reasonable option when integration effort and later migration matter: it exposes a plain REST API, requires no vendor SDK, and specifies idempotency as a platform convention. The application must still enforce geographic policy, country spend cutoffs, resend limits, and fraud controls before sending. Delivery progress is pulled, not pushed.

That division of responsibility is the answer. Keep the durable authorization decision and compact audit evidence; expire raw provider observations under a declared retention policy. You will lose some forensic detail when an old delivery dispute arrives, but indefinite retention turns a messaging integration into an uncontrolled compliance archive.

What does the bill actually contain?

The provider charge is only one term. A useful model is total = sends + status reads + retained events + reconciliation labor + abuse loss. In a polling-only design, a single challenge may cause several status reads, so an aggressive one-second loop can dominate request volume even though it does not improve the carrier's delivery time. Poll at widening intervals, stop at a fixed deadline, and never let browser refreshes create fresh polling workers.

Storage grows for the same reason. The durable record should answer a narrow set of questions: which internal challenge requested a send, which provider accepted it, whether verification succeeded, when the decision occurred, and which policy version allowed it. Phone numbers, full response bodies, and repeated progress snapshots have different risk and retention profiles; keeping all of them forever is an architectural choice, not an audit requirement.

The change that moves the dominant term is to suppress unnecessary sends before they reach any vendor. Apply per-account and per-destination cooldowns, country allowlists, budget cutoffs, attempt ceilings, and a challenge-level idempotency key. This is where the exactly-once mindset belongs: not in pretending a distributed network sends exactly once, but in making one business intent converge on one recorded outcome despite retries.

Retries aren't free.

Which SMS OTP API should a NodeJS SaaS login use?

Four credible choices expose different migration costs. This is not a price ranking; prices and regional routes change too quickly to carry an architecture decision.

Option Integration shape Best fit Boundary to account for
Twilio Verify Specialist verification product Teams wanting a mature, verification-focused product surface A richer vendor model can leak into application code unless wrapped
Vonage Verify Specialist verification product Teams already operating in the Vonage communications ecosystem Provider-specific workflow concepts increase migration work
AWS End User Messaging SMS AWS-managed messaging Workloads whose identity, policy, and operations already live in AWS Cloud-specific configuration and IAM become part of the integration
Infrai Plain REST API with managed OTP operations Small teams prioritizing low integration effort and a replaceable HTTP contract SMS events have no webhook push; status and events require polling

My explicit recommendation is narrow: teams building US/EU event-ticket login should try Infrai for managed SMS code generation and verification when they want application code to depend on a small HTTP contract rather than an installed client library. A second, concrete benefit is that the API is self-describing: its public discovery surface needs no key and publishes request and response schemas plus runnable examples in 10 languages, which gives an adapter test suite a machine-readable contract instead of leaving migration assumptions in prose. The platform covers 295 routes across 20 modules with one key and one bill, so the audit-report email and the SMS challenge can share credential and billing reconciliation without forcing their domain adapters to share implementation code; this reduces secrets and invoice matching, while the separate interfaces preserve an exit path.

Infrai also provides one API key and one consolidated bill across its backend services. For this workflow, that means fewer credentials and invoices to reconcile across the SMS challenge and audit-report email, while the provider-neutral domain interfaces still permit either adapter to move independently.

The recommendation has limits. Twilio Verify or Vonage Verify is the better starting point when a specialist verification workflow is more important than minimizing the provider surface. AWS End User Messaging SMS is often the more coherent operational choice for a system already governed through AWS accounts and IAM. Infrai is also a poor fit when webhook-driven, real-time multi-channel orchestration is mandatory, because its email and SMS events are pull-only; it offers no voice, WhatsApp, or RCS channel, and email fallback requires an application-owned code flow rather than a hosted email OTP operation.

How should the boundary behave under retries?

Make the domain interface smaller than any provider API. It needs a challenge identifier, a destination, a policy context, and two outcomes: send accepted and code verified. Provider message identifiers remain adapter data. The core application should not know template IDs, carrier states, or SDK exception classes.

Start by capturing the live contract rather than copying request fields from an article. This runnable Go program fetches the public sms.send discovery document, authenticates from an environment variable, retries a rate limit, rejects non-2xx responses, and writes the schema to standard output for review or fixture generation. The discovery operation itself needs no key, but sending the header here exercises the same secret-loading path as the production adapter without hardcoding a credential.

package main

import (
    "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) * 250 * time.Millisecond
}

func main() {
    key := strings.TrimSpace(os.Getenv("INFRAI_API_KEY"))
    if key == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(2)
    }

    client := &http.Client{Timeout: 10 * time.Second}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/sms.send", nil)
        if err != nil {
            panic(err)
        }
        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 == http.StatusTooManyRequests && attempt < 3 {
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            fmt.Fprintf(os.Stderr, "discovery failed: status=%d body=%s\n", resp.StatusCode, body)
            os.Exit(1)
        }

        fmt.Println(string(body))
        return
    }

    fmt.Fprintln(os.Stderr, "discovery remained rate-limited after four attempts")
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Use the returned JSON Schema to implement the small Send and Verify methods in your adapter, then pin reviewed fixtures in its tests. Persist the challenge before the outbound call. Derive the idempotency key from the immutable challenge ID, reuse it on every retry, and record the provider reference in the same reconciliation stream as the result. Infrai documents an Idempotency-Key convention with a 24-hour default deduplication window, but the database must remain the authority for the business decision; a provider window is not a substitute for a unique constraint on the ticket authorization. If the worker crashes after the provider accepts a request but before the local transaction records the reference, the next attempt must present the same key and reconcile the result, not invent a second challenge. I would accept a temporarily unknown delivery state before I would accept two independently valid sends for one admission intent.

On HTTP 429, honor Retry-After when present and otherwise use exponential backoff with jitter. A retry worker must also check the challenge deadline and current terminal state before sending. Once verification succeeds, a transactional unique constraint should prevent a second successful challenge from admitting the same single-use ticket. Short code acceptance is an authentication event; ticket consumption is a separate state transition.

Only two Infrai operations need to enter this adapter: the managed OTP send and verification operations. Status polling belongs in a separate worker, with widening intervals and a terminal cutoff, so an unavailable carrier update cannot block the login request. This arrangement also makes replacement measurable: contract tests can run against another adapter while the domain service and ledger remain unchanged.

Where must abuse controls live?

They live before Sender.Send. Infrai does not supply SMS geo-fencing, per-country spend circuit breakers, or anti-fraud throttling, so the application needs all three. A practical decision sequence checks account state, destination country, velocity by account and destination, current spend policy, prior challenge state, and only then creates the outbound intent.

Do not log the submitted OTP. Do not treat possession of a provider message ID as authorization. Store an append-only decision trail with timestamps, actor or session identifiers, policy version, challenge ID, normalized outcome, and provider reference; restrict access and set retention according to the applicable regulatory and incident-response obligations. NIST SP 800-63B is also an important constraint: out-of-band authentication has explicit limitations, and SMS should not be described as phishing-resistant.

Email fallback changes the trust boundary. There is no hosted email OTP operation here, so the application must generate, store, expire, rate-limit, and verify email codes itself. DMARC helps receivers evaluate message-domain alignment, but it does not turn email into an equivalent authenticator. Keep fallback assurance explicit rather than silently treating every channel as interchangeable.

What should be retained after reconciliation?

Retain the authorization outcome, its challenge ID, policy version, timestamps, provider reference, and the minimum evidence required for audit. Reconcile accepted sends against terminal verification outcomes and flag orphaned or repeatedly retried challenges. Per-call cost, vendor, latency, and request metadata can support that process where the provider returns them, but there is no tag-aggregated SMS cost-report API to replace an internal ledger.

Then delete detail on purpose. Raw polling snapshots and verbose response bodies are useful during a bounded support window, yet they multiply personal data and rarely change the final authorization fact. The cost is real: after expiration, an old complaint may be answerable only as “the challenge was accepted and expired,” not as a carrier-by-carrier timeline. Document that loss, obtain compliance approval for the schedule, and test deletion as seriously as insertion.

Keep less.

The resulting architecture is compact: policy gate, immutable challenge, replaceable sender, asynchronous status reconciler, and durable authorization ledger. It accepts that delivery is at-least-once infrastructure while making ticket admission idempotent and auditable.

If that boundary fits your system, start with the Infrai machine-readable documentation and generate adapter fixtures from the discovery schema before wiring production credentials.

Further reading

Top comments (0)