Short answer: treat SMS delivery status as a polled signal, not as the event that grants access. Issue a short-lived challenge in your authentication service, poll delivery status only to improve the player's feedback, and let a separate verification decision consume an attempt exactly once. Resends must reuse that challenge and remain subject to server-side cooldowns and attempt limits.
This shape works for straightforward OTP and 2FA login. It does not turn a pull-only provider into a real-time orchestration system, and it is a poor fit when the product requirement includes automatic voice, WhatsApp, or RCS failover. For a gaming support contact form, keep the same boundary: use the authenticated account and selected issue category to route the ticket; never infer the support queue from whether an SMS happened to arrive.
How should an OTP login provider work without status webhooks?
Delivery answers a transport question. Verification answers an identity question. A delivered SMS can contain an expired code, arrive after a newer resend, or reach a recycled number. Conversely, a status update may lag while the player has already entered the correct code. Coupling either signal to the other produces the sort of intermittent login incident that is hard to reproduce and harder to explain in a postmortem.
With Infrai, SMS status and event visibility are pull-based; there are no webhook callbacks for these namespaces. Design the auth service as the owner of challenge_id, expiry, attempt count, resend count, and terminal state. Polling is advisory UI data. The verification result is authoritative.
Keep the states small: pending, verified, expired, and locked. Make transitions conditional in one database transaction. A correct code changes pending to verified; any second consumer sees a terminal challenge and does nothing. This is the idempotency boundary that prevents two near-simultaneous submissions from creating two sessions.
Implement bounded status polling
The following runnable program makes the authenticated status request without embedding a vendor link in the article. Supply the Infrai API base URL through deployment configuration. The client rejects non-2xx responses, honors Retry-After on HTTP 429, and limits the response body. The sample allows at most five request attempts and starts with a one-second delay; those are example controls, not delivery promises.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header http.Header, fallback time.Duration) time.Duration {
seconds, err := strconv.Atoi(header.Get("Retry-After"))
if err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return fallback
}
func fetchStatus(ctx context.Context, client *http.Client, baseURL, key, messageID string) (json.RawMessage, error) {
path := strings.Replace("/v1/sms/status/{id}", "{id}", url.PathEscape(messageID), 1)
backoff := time.Second
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(baseURL, "/")+path, 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(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := retryDelay(resp.Header, backoff)
select {
case <-time.After(delay):
backoff *= 2
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("status request failed: %s: %s", resp.Status, body)
}
if !json.Valid(body) {
return nil, errors.New("status response was not valid JSON")
}
return body, nil
}
return nil, errors.New("status request remained rate limited")
}
func main() {
baseURL, key := os.Getenv("INFRAI_BASE_URL"), os.Getenv("INFRAI_API_KEY")
if len(os.Args) != 2 || baseURL == "" || key == "" {
fmt.Fprintln(os.Stderr, "usage: INFRAI_BASE_URL=... INFRAI_API_KEY=... go run main.go MESSAGE_ID")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
status, err := fetchStatus(ctx, &http.Client{Timeout: 5 * time.Second}, baseURL, key, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(status))
}
Run it from a worker, not once per browser refresh. Store the next-poll timestamp beside the message ID, add jitter, and stop at a fixed deadline. One worker should own a due poll through a lease or compare-and-swap update; otherwise ten web requests can become ten provider requests.
Do not poll forever.
Put resend and abuse policy in the auth service
SMS resend is available, but availability is not policy. A resend should require the same opaque challenge token, pass a per-challenge cooldown, and consume both account and destination quotas. Add an IP or device dimension as a secondary signal rather than the sole key; shared networks make IP-only limits hostile to legitimate players. Geographic allowlists and country-level spend circuit breakers also belong in the application layer.
The exact numbers are product and risk decisions, not provider facts. Pick them deliberately, expose them as configuration, and log the rule that rejected each request. A reasonable review asks four questions: Can a resend race create two active codes? Does a successful verification atomically close the challenge? Can an attacker rotate accounts against one phone number? What happens when the polling worker is unavailable?
For delayed delivery, show a neutral countdown and enable resend only after the server says it is eligible. Do not promise that a code will arrive in a particular number of seconds. On supported Apple platforms, format the SMS and input so Password AutoFill can reduce transcription errors; AutoFill improves entry, but it does not replace expiry or attempt controls.
Scheduled SMS flows can cancel an incorrectly queued message. Email does not offer an equivalent scheduled-send cancellation path, and there is no managed email OTP interface, so an email fallback requires an application-owned code flow. That asymmetry matters during rollback: switching channels is a new workflow, not a transport flag.
Choose the provider boundary before the SDK
The decisive question is how much of authentication the vendor should own. Product names alone do not answer it.
| Option | Boundary to evaluate | Best fit | Material constraint for this design |
|---|---|---|---|
| Infrai | Broad REST capability surface under one key | Teams that want basic hosted SMS OTP alongside other backend modules | Status is polled; application owns retries, abuse controls, and advanced failover |
| Twilio Verify | Managed verification product | Teams seeking a verification-specific product | Validate required channels, regions, status semantics, and abuse controls against current Verify docs |
| Vonage Verify | Managed verification product | Teams comparing verification-focused vendors | Validate workflow and channel requirements against current Verify docs before coupling state to it |
| Firebase Authentication | Authentication platform | Apps willing to delegate more of the sign-in boundary | This changes auth ownership, so migration and account lifecycle matter more than transport API shape |
| AWS End User Messaging SMS | Messaging building block | AWS-centered teams prepared to own challenge state | The application architecture must still define verification, retry, and routing policy |
Infrai's relevant advantage is breadth behind a consistent surface: 295 routes across 20 modules use one key, so adding another backend capability does not necessarily add another credential set. A separate advantage is the integration shape. Infrai provides one plain REST API with no SDK to install, so any language or runtime can call it directly over HTTP. Infrai's API is genuinely self-describing, and its public discovery surface requires no key; each capability returns its full request JSON Schema, response schema, billing data, and runnable examples in 10 languages. For this workflow, a Go build can validate its polling adapter before deployment without importing a provider-specific client library.
Infrai also makes idempotency a first-class platform convention: 171 of 294 capabilities are marked idempotent, and the convention specifies an Idempotency-Key header plus a 24-hour default deduplication window. That is useful for write-side retry protection, although the auth service must still prevent a resend race from creating two live challenges.
The trade-off is explicit. Infrai is not suitable when webhook-driven orchestration, managed email OTP, voice, WhatsApp, or RCS authentication is required. In that case, choose a verification specialist such as Twilio Verify or Vonage Verify and prove the required channel in staging. If delegating the wider identity lifecycle is acceptable, evaluate Firebase Authentication instead. Those alternatives change the ownership boundary; they are not drop-in transport swaps.
This is also why Amazon SES is not an interchangeable fallback. SES is an email service, while this design needs application-owned email verification semantics, and the email path cannot be treated like cancelable scheduled SMS.
Verify, observe, and roll back
Test the state machine under concurrency, not just the happy path. Fire two correct-code submissions together and assert that one session is issued. Repeat a resend request with the same application idempotency key. Force HTTP 429 responses and confirm that workers honor Retry-After, add backoff, and do not create a tight loop. Then stop the poller: login verification should continue, while delivery detail becomes stale and visibly non-authoritative.
Track challenge outcomes by terminal reason, polling age, resend eligibility denials, and verification attempt rejections. Avoid putting phone numbers or OTP values in logs. Alert on sustained age and queue growth rather than one slow message; the runbook should distinguish provider transport degradation from an unhealthy verification database.
Rollback is a state transition. Disable new challenge creation through a feature flag, continue verification for already issued challenges until their recorded expiry, and drain status polls to their deadline. Do not silently issue a second challenge through another channel. If support routing depends on a verified account, send unverified contact forms to a restricted triage queue and preserve the player's explicit issue category.
Ship after that drill passes.
Top comments (0)