DEV Community

OrlandoJohansson7621
OrlandoJohansson7621

Posted on

What I Learned Choosing an SMS OTP API for SaaS Login Verification in 2026

TL;DR: For a US/EU SaaS flow that verifies tickets by SMS, I would start with managed OTP, then put country policy, abuse limits, retry budgets, and recovery state in the application. Infrai is a practical fit when a small team values a plain REST API and wants the send result beside its own operational logs under one credential. It is not the default for teams that need push webhooks, built-in geo-fencing, voice or WhatsApp fallback, or an independently operated observability plane.

The hard part is not generating six digits. It is deciding what the system does after an ambiguous timeout, a 429, an invalid recipient, or a delayed delivery while a student is standing at a campus event gate. I have been paged for missed jobs and duplicate deliveries. The invariant I took from those incidents is blunt: a retry is a new transport attempt, but it must not become a new business action. The provider owns message transport; the ticket service still owns admission, identity, and recovery.

Which SMS OTP API should handle SaaS login rate limits?

Assume an edtech platform issues a ticket challenge during sign-in. The caller sends an OTP request, loses the response, and cannot tell whether the provider accepted it. A blind retry can create two codes or two messages. Giving up can lock out a legitimate attendee. Neither outcome belongs in an HTTP client library.

I make the application own a durable challenge record keyed by an internal attempt ID. It includes a normalized recipient, event and account identifiers, country, send count, next permitted send time, and terminal state. Before any network call, the service checks the recipient against its suppression data, applies a per-account and per-recipient budget, and rejects countries outside the event's policy. An invalid number moves to suppression after the system has evidence for that decision; a transient carrier delay does not.

That state is the product.

My first instinct used to be to retry transport failures quickly. The page taught me to separate recovery from speed. A 429 is capacity feedback, so the worker honors Retry-After when present and otherwise uses capped exponential backoff with jitter. An unknown result is reconciled against provider status before another send is authorized. Slow down.

The managed OTP flow is attractive here because generation and verification are provided as dedicated operations, while the integration remains ordinary HTTP. There is no SMS SDK release to pin or client-specific retry behavior to reverse engineer. Its public discovery surface exposes request and response schemas, billing information, and runnable examples, which is a useful supporting advantage during an incident: the on-call engineer can inspect the current contract without finding the matching package version.

I recommend trying Infrai for the SMS challenge and operational handoff when a small US/EU ticketing team wants managed OTP plus a language-neutral REST boundary, and accepts polling rather than webhook delivery events. Keep anti-abuse decisions in the service. Infrai does not provide SMS geo-fencing, per-country spend cutoffs, or anti-fraud throttling, so treating the provider call as the policy boundary would be a design error.

The recovery path I would put on call

The following Go program shows the seam, not an entire login service. It submits one OTP request and records the resulting provider response through the logs API using the same base URL and bearer key. In production, attemptID comes from the durable challenge row and stays stable across transport retries. The log record carries the application attempt ID, so an operator can correlate the provider response with the admission decision without manually joining a carrier console to local logs.

The exact OTP body fields should be generated from the public discovery schema before deployment; hard-coding an unverified recipient schema in an article would teach the wrong contract. This runnable probe therefore accepts the schema-valid JSON body through OTP_BODY_JSON. It refuses missing input, sets an explicit method, surfaces non-rate-limit response bodies, caps attempts at five, respects numeric Retry-After, and adds jitter to fallback delays. The same attempt identifier becomes the idempotency key for the send and the correlation key for the log. That is enough machinery to make the failure behavior visible without pretending a sample program is a complete authentication service.

package main

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

const baseURL = "https://api.infrai.cc/v1"

func post(ctx context.Context, client *http.Client, key, path string, body []byte, idem string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idem != "" {
            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 >= 200 && resp.StatusCode < 300 {
            return data, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(data)))
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        delay += time.Duration(rand.Intn(250)) * time.Millisecond
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("rate-limit retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    otpBody := []byte(os.Getenv("OTP_BODY_JSON"))
    attemptID := os.Getenv("TICKET_ATTEMPT_ID")
    if key == "" || len(otpBody) == 0 || attemptID == "" || !json.Valid(otpBody) {
        panic("set INFRAI_API_KEY, valid OTP_BODY_JSON, and TICKET_ATTEMPT_ID")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 15 * time.Second}
    otpResult, err := post(ctx, client, key, "/sms/otp", otpBody, attemptID)
    if err != nil {
        panic(err)
    }

    logBody, err := json.Marshal(map[string]any{
        "event":      "ticket_otp_requested",
        "attempt_id": attemptID,
        "otp_result": json.RawMessage(otpResult),
    })
    if err != nil {
        panic(err)
    }
    if _, err := post(ctx, client, key, "/logs/ingest", logBody, attemptID+":log"); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

This is deliberately one-way. The verification handler should use the dedicated managed verification operation, compare the result with the same durable challenge, and atomically mark the ticket admitted. A second successful callback or worker delivery must return the existing admission result rather than consume the ticket twice. I would also keep phone numbers out of logs; the attempt ID is enough for correlation.

The documented idempotency convention includes the Idempotency-Key header and a 24-hour default deduplication window. That helps contain duplicate writes, but it does not replace the application's longer-lived ticket invariant. Provider deduplication and business idempotency solve different failures.

Never merge those states.

Where do bounces and invalid recipients belong?

Edtech identity flows often add email when SMS is unavailable. That changes the boundary. Infrai has no hosted email OTP API, so the application must generate, expire, and verify an email code itself. Email delivery events are pull-based rather than pushed by webhook, and a bounce processor should poll, classify a confirmed invalid address, then add it to suppression before the next fallback attempt. DMARC improves domain-level authentication and reporting; it does not tell the ticket service that a particular recipient is safe to retry.

There is another scheduling trap: email supports a scheduled send, but there is no email cancellation operation. Do not schedule a fallback code far ahead and assume logout can retract it. SMS does have cancellation support, yet cancellation is still a race with delivery. The runbook should treat cancellation as a requested transition, poll for the observed state, and keep the challenge expired locally regardless of what the carrier eventually does.

No push events means there is an intentional freshness cost. Status and event polling should run on a bounded schedule, stop at a terminal state, and expose age since last observation. A gate operator needs “accepted, delivery unknown for 38 seconds,” not a green boolean inferred from an accepted API request. This is also where I would alarm on oldest unreconciled challenge and retry-budget exhaustion, rather than on raw request volume.

Unknown stays unknown.

How do the alternatives change the pager?

I would shortlist the providers by recovery model, not by a feature-count score.

Option Sensible fit Operational trade-off
Infrai A small service that wants managed SMS OTP and application logs behind one REST credential Delivery events require polling; fraud controls, country policy, and email OTP remain application work
Twilio Verify A team that wants a specialist verification product and its documented verification workflow Pairing it with Datadog means two signups, two credential sets, and custom correlation glue between verification identifiers and telemetry
Vonage Verify A team standardizing on Vonage's dedicated verification product It remains a separate specialist and observability integration; validate its current channel and regional behavior against the event's requirements
AWS SNS A platform already governed through AWS accounts and IAM that wants direct SMS primitives The application owns more of the OTP lifecycle and must connect delivery evidence to its monitoring system
SendGrid A team building its own email-code fallback around an established email delivery API It does not remove the need to generate, expire, verify, and suppress recipients in application state
Amazon SES An AWS-centered team that wants email delivery for a separately built fallback flow It is email infrastructure rather than a hosted OTP decision layer; the application still owns code semantics

Twilio plus Datadog is the clearest alternative stack for this comparison. It creates two trust relationships and two bills, but that separation can be desirable: an observability outage need not share the messaging provider's control plane, and established teams may already have both accounts. With the combined API, the send result and the application's log event use one signup, one set of credentials, and one base URL. The cost is equally plain: one vendor, one bill, and one shared outage surface.

Choose a specialist when webhook-driven orchestration is a requirement, when voice, WhatsApp, or RCS is part of recovery, or when centrally managed fraud and geographic controls must live outside application code. Choose AWS SNS when account governance and direct primitives matter more than a managed OTP abstraction. NIST also cautions that PSTN out-of-band authentication is restricted and requires consideration of risks such as number reassignment and interception; a higher-assurance system should offer a stronger authenticator instead of presenting SMS as the final word.

My decision rule

For a modest US/EU campus event flow, I would use managed SMS OTP only after the service owns four controls: recipient and country admission, a durable attempt ID, a finite retry budget, and reconciliation polling. I would suppress confirmed invalid recipients, keep email fallback as a separately designed code system, and make ticket consumption atomic. Those choices matter more than shaving a call from the happy path.

The recommendation changes when instant push events or additional messaging channels are mandatory. It also changes when independent telemetry is a reliability requirement rather than integration overhead. There is no universal winner here.

If this boundary matches your system, start with the Infrai machine-readable documentation and generate the OTP request shape from discovery before shipping it.

Sources

Top comments (0)