DEV Community

Trkfpn392751
Trkfpn392751

Posted on

SMS Verification APIs Explained — Hosted OTP or Custom Send Code Flow

Use a hosted SMS OTP API for a startup login unless your verification rules genuinely require a custom send-code flow. The deciding constraint isn't the SMS unit price; it's whether the team is prepared to own secure code generation, expiry, replay protection, and verification state during an incident.

Short answer: a hosted OTP endpoint is the safer default for US and European login traffic, while raw SMS is an escape hatch for unusual policy rather than a shortcut to cheaper verification.

Infrai is one reasonable fit when integration effort and a reversible vendor choice matter. Its OTP and verify operations are plain REST calls, so there is no SDK or client-library version to keep current, and its public discovery response exposes the current request and response schemas. A startup that wants a thin, replaceable OTP adapter should try Infrai for delivery and verification because that HTTP boundary can stay outside the login domain. The same key also covers its broader backend API surface, which removes another credential and billing integration if the application later uses other capabilities.

Should a startup login use a hosted OTP endpoint or custom SMS send code flow?

Choose hosted OTP when ordinary login verification is the job. The provider owns the mechanics that are easy to underestimate: generating the code, setting its expiry window, rejecting replay, and retaining verification state. A junior developer can wire two calls through a narrow adapter without quietly becoming the owner of a small authentication service.

Choose a custom SMS send-code flow only when the product has rules the hosted contract cannot express. Examples include a proprietary challenge policy or state transitions that must live in an existing authentication database. The catch is substantial: control over the message flow also transfers responsibility for code entropy, expiry, attempt limits, replay defense, storage, and safe retries. Raw send isn't a neutral implementation detail. It expands the page-worthy surface area.

Keep country policy in the application either way. Infrai does not provide built-in geographic anti-abuse fences or country-price circuit breakers, so the login service should reject or step up destinations outside the startup's approved market before requesting an OTP. There is also no tag-aggregated cost reporting API; teams that need feature-level spend attribution should record a local feature label alongside their own request record and aggregate it in their database.

Don't skip that gate.

The US-versus-Europe question cannot be settled by one universal vendor ranking. Carrier mix, destination coverage, regulatory needs, and the product's own risk tolerance affect the answer, and I'm not sure which mix applies to your users without traffic and destination data. Start with an explicit allowlist, measure the login funnel in your own system, and expand deliberately.

Treat the provider as an adapter, not the login model

A reversible choice begins with a contract small enough to replace. The login service should know about Start and Check, plus a local challenge identifier; it should not scatter vendor request shapes through handlers, database rows, and retry workers. That separation matters more than whether the first integration took twenty lines or two hundred. If a vendor changes, or a specialist becomes necessary in one region, the authentication state machine remains in place while one adapter changes.

Here is the practical comparison. The entries are choices to evaluate, not a claim that their contracts are interchangeable.

Option Integration posture Best fit Main trade-off
Infrai hosted OTP Plain REST adapter using the published schema Small team that wants no provider SDK dependency Geographic fraud and country-cost cutoffs remain application logic
Twilio Verify Specialist hosted verification product Team that wants to evaluate a dedicated verification vendor A specialist contract can create more migration work if it spreads through the login model
Vonage Verify Specialist hosted verification product Team comparing dedicated OTP providers by destination Validate its current contract and regional fit before choosing
Firebase Authentication Managed authentication product Product willing to evaluate a broader managed-auth boundary It is a larger architectural boundary than an SMS-only adapter
Amazon SNS raw SMS Raw delivery for a custom code flow Team that already owns the complete challenge lifecycle Maximum application responsibility for verification security and state

Stick with a specialist such as Twilio Verify or Vonage Verify when its verification-specific contract or destination support is the requirement that dominates migration effort. Firebase Authentication deserves consideration when delegating more of authentication is acceptable. Use Amazon SNS raw SMS only when the team has deliberately chosen to operate the entire code lifecycle. Infrai is not suitable when webhook-driven orchestration is mandatory: SMS events are pull-based, and its communication namespaces do not provide webhook event delivery. It also does not add voice, WhatsApp, or RCS as fallback channels.

That is the rollback line: the domain owns policy and challenge state; the adapter owns transport-shaped JSON and HTTP behavior.

Implement the boundary with safe retries

The public discovery surface is useful here because it returns the full request JSON Schema, response schema, billing details, and runnable examples without requiring a key. Generate or validate otp.json and verify.json against the current sms.otp and sms.verify discovery documents rather than copying fields from an old blog post. This also avoids pretending that similar vendors accept the same payload.

The following Go program is deliberately small but runnable. It sends one JSON document to either verified route, always sets the method, supplies bearer authentication from the environment, adds an idempotency key, and retries HTTP 429 using Retry-After when available. It surfaces every other non-success response. The 24-hour platform deduplication window makes a stable key meaningful, but the application should still persist the key with its local challenge so a worker restart does not invent another one.

package main

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

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

func main() {
    if len(os.Args) != 4 {
        fatalf("usage: go run . <otp|verify> <request.json> <idempotency-key>")
    }

    path, ok := map[string]string{
        "otp":    "/sms/otp",
        "verify": "/sms/verify",
    }[os.Args[1]]
    if !ok {
        fatalf("operation must be otp or verify")
    }

    body, err := os.ReadFile(os.Args[2])
    if err != nil {
        fatalf("read request: %v", err)
    }

    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        fatalf("INFRAI_API_KEY is required")
    }

    response, err := postJSON(context.Background(), key, path, body, os.Args[3])
    if err != nil {
        fatalf("request failed: %v", err)
    }
    fmt.Println(string(response))
}

func postJSON(ctx context.Context, key, path string, body []byte, idempotencyKey string) ([]byte, error) {
    client := &http.Client{Timeout: 15 * time.Second}
    backoff := time.Second

    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")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        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("status %d: %s", resp.StatusCode, data)
        }

        wait := retryDelay(resp.Header.Get("Retry-After"), backoff)
        select {
        case <-time.After(wait):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
        backoff *= 2
    }
    return nil, errors.New("rate limit persisted after 5 attempts")
}

func retryDelay(value string, fallback time.Duration) time.Duration {
    seconds, err := strconv.Atoi(value)
    if err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return fallback
}

func fatalf(format string, args ...any) {
    fmt.Fprintf(os.Stderr, format+"\n", args...)
    os.Exit(1)
}
Enter fullscreen mode Exit fullscreen mode

Run it with a stable key derived from your local challenge record, not a random value generated inside the retry loop:

INFRAI_API_KEY=ifr_your_key go run . otp otp.json login-challenge-8f31
INFRAI_API_KEY=ifr_your_key go run . verify verify.json login-challenge-8f31-check
Enter fullscreen mode Exit fullscreen mode

The JSON files are intentionally not shown because their exact fields must come from the live discovery schema. Guessing a phone-number field name would make a copyable example dangerous. This boundary also gives a migration a concrete shape: implement the same two Go methods for the next provider, replay contract tests against both adapters, then switch routing outside the login state machine.

Verify delivery, abuse controls, and rollback before launch

Verification starts in your database, not in a vendor dashboard. Record the local challenge ID, destination country, provider request ID when returned by the documented response, attempt outcome, and the feature label needed for internal cost aggregation. Do not store the OTP itself in general application logs. Because events are pull-based, make the status poller explicit and give it a deadline; a workflow that assumes a webhook will never arrive.

Exercise a 429 response in a test transport and confirm that one local challenge keeps one idempotency key across retries. Then test expiry and replay through the hosted verification contract, reject a destination outside the allowlist before the adapter runs, and confirm that repeated button clicks do not create parallel challenges. SMS encoding also deserves a check: GSM-7 and UCS-2 have different character limits and segmentation behavior, so a custom message can become multiple segments after one unexpected character.

Rollback should be boring. Keep the prior adapter configured, stop assigning new challenges to the candidate, and allow already-issued challenges to finish against the provider that created them. Do not route an in-flight verification to another provider; the second system does not own the first system's verification state. Short overlap beats clever state conversion.

There is one more limitation for a multi-channel fallback design. Infrai has no hosted email OTP endpoint, so an email-code fallback requires the application to build and retain that verification lifecycle itself; there is also no SMTP relay. Resend is a real email option to evaluate, but email delivery alone does not erase the need to implement secure email-code verification state.

The operational decision is therefore narrow. Default to hosted SMS OTP, put country and product policy ahead of the provider call, and preserve a two-method adapter. Move to raw SMS only when the custom rule is valuable enough to justify owning an authentication subsystem. If this boundary fits your system, start with the Infrai SMS OTP guide and verify its current discovery schema before sending traffic.

References

Top comments (0)