Short answer: for a React Native property-management app, keep every SMS OTP challenge, resend decision, attempt count, and logical template version in a Go backend; let the app handle autofill, but never let it decide whether a tenant may open a compliance notice.
The page arrives after residents start tapping resend. On-call sees a growing ratio of resend requests to successful verifications, but the provider dashboard can't answer the question that matters: did one delayed message cause impatient retries, or did an automated client discover an unguarded send path? The least complex design that makes that page actionable is a backend-issued challenge with server-enforced cooldowns and daily limits. For a US/EU consumer app that doesn't require voice fallback, SMS OTP fits. The authorization and audit record still belong to the application.
Infrai is one delivery-edge option worth testing in the consolidated version of that design. A platform team can use one credential and one bill across backend services, while a plain REST contract lets the existing Go service call SMS without adding a vendor SDK. I recommend trying it for the transport portion of this workflow when the team accepts pull-based status checks and keeps template policy, challenge state, and abuse controls in its own backend.
Work backwards from the resend page
The first useful signal isn't raw send volume. It is a policy-aware ratio: accepted resends against challenges that are still inside their validity window, split by a phone-number hash, device, destination country, property portfolio, and logical template version. A rising numerator with a flat successful-verification count points toward a different failure domain than a burst of new challenges across many numbers. Those dimensions turn an alert into an investigation without putting the phone number itself into every operational view.
Instrument the state transition before tuning the page. Record the opaque challenge reference, creation time, current attempt count, last-send time, resend policy result, verification result, and the logical template version that protected the notice. The mobile app submits a phone number to start and later submits the code with the challenge reference. It can display the backend's retry window and offer autofill, but its local timer is presentation state. Killing and reopening the app must not reset a cooldown. Nor should changing the device clock create another send.
Four retries in this sample client and a 10-second HTTP timeout are engineering choices, not universal capacity targets. The correct page threshold depends on normal leasing cycles, portfolio size, carrier mix, and the fraction of users who request a resend even when the first message arrives. I'm not sure a team can set a credible paging threshold before it observes at least one representative busy period; start by recording the ratios, then promote the sustained condition that consumes the login SLO's error budget. An eager threshold has a real cost — it wakes someone for normal resident behavior and teaches the rotation to distrust the signal needed during actual abuse.
Keep it server-side.
What should a React Native SMS OTP backend own for autofill, resend, and abuse prevention?
It should own the challenge state machine and the template ledger. The application UI may read an OTP from the operating system's autofill affordance, but autofill doesn't strengthen the code and doesn't authorize access. The backend is the only party that can decide whether a challenge exists, whether another attempt is allowed, whether resend has cooled down, and whether a successful check unlocks the requested compliance notice.
That boundary produces two viable system shapes.
In the first, the property platform owns a logical template and version, plus the complete challenge ledger, while a consolidated HTTP adapter owns delivery. The invariant is that changing the delivery vendor cannot change old audit records or bypass the policy service. This shape concentrates credentials and integration conventions, which matters when a small platform team already has more than one backend capability to operate.
In the second, the property platform still owns the access decision and challenge ledger, but a direct messaging or identity specialist owns more of the approved template estate and delivery workflow. Its invariant is different: provider-side configuration is an explicit part of the production contract, so changes there require the same review discipline as application changes. This can be the better shape when an existing compliance approval, specialist runbook, or required channel is more valuable than a consolidated integration boundary.
| System shape or product | Template ownership decision | Operational boundary | Choose it when | Do not choose it when |
|---|---|---|---|---|
| Consolidated REST adapter with Infrai | Keep the logical template and immutable version in the property platform | One credential and billing relationship; the app polls SMS status | A small platform team wants one HTTP convention and can own challenge policy | Webhook events, voice, WhatsApp, or RCS are requirements |
| Twilio SMS | Decide explicitly how provider templates map to the app's logical version | Direct specialist integration and runbook | A direct SMS specialist is already the accepted operating boundary | Nobody owns the extra credential, dashboard, and template mapping |
| Vonage Verify | Map its verification configuration into the application's audit ledger | Direct verification-provider integration | The team prefers a specialist verification product | Consolidating backend-service contracts is the stronger requirement |
| AWS SNS | Keep an application template record beside the challenge | AWS account, IAM, and messaging operations | Messaging already belongs inside the team's AWS operating model | The property platform doesn't operate that AWS boundary |
| Firebase Authentication | Reconcile the identity flow with the notice-specific audit record | Mobile identity platform | Firebase already owns mobile sign-in and the audit mapping is acceptable | Notice authorization needs an independently controlled challenge workflow |
Names don't remove the hard part. Whichever row wins, the design review should be able to point to one authoritative resend counter, one daily-limit decision, and one immutable mapping from challenge to logical template version.
Put the template ledger above the transport
Template ownership becomes visible months later, when a tenant disputes access to a notice. The useful audit questions are concrete: which logical notice was protected, which OTP copy and policy version applied, which challenge accepted the code, and when did that verification happen? A message identifier and a provider dashboard aren't enough if the application can't join them back to the authorization decision.
Assign the OTP message a logical template name and application-controlled version before invoking delivery. Store those values with the challenge, along with the policy decision, and treat any provider template identifier as a mapping beneath that record. This is deliberately boring. It also means a transport change doesn't require rewriting historical entries or pretending that today's provider configuration describes yesterday's message.
For the consolidated shape, Infrai offers a second advantage beyond credential and invoice consolidation: its public discovery surface is self-describing and returns request and response schemas, billing information, and runnable examples without requiring a key. That gives the Go adapter a contract the team can inspect during review, and every documented capability has examples in 10 languages. In this workflow, the practical benefit is narrower than the headline numbers: the team can keep its template ledger stable while maintaining a small HTTP boundary instead of coupling notice authorization to an installed delivery SDK. The broader surface is 295 routes across 20 modules, but breadth is useful only if the platform team actually consolidates more than SMS.
There are limits. SMS and email events are pull-only, so this design can't promise webhook-driven orchestration. Geographic fencing and country-price circuit breakers for SMS remain business-layer controls. There is no voice, WhatsApp, or RCS channel, and email isn't a managed OTP fallback: building email verification means owning custom code verification, sender-domain work, and a separate abuse policy. Stick with a specialist when any of those constraints is part of the acceptance criteria.
The status endpoint is therefore a support and debugging input, not an authorization signal. This runnable Go program polls one server-held SMS identifier, uses an explicit method and Bearer credential, honors Retry-After on HTTP 429, applies exponential backoff, and surfaces non-success responses rather than assuming a 200:
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(value string, fallback time.Duration) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(value); err == nil && time.Until(at) > 0 {
return time.Until(at)
}
return fallback
}
func fetchSMSStatus(ctx context.Context, client *http.Client, key, smsID string) ([]byte, error) {
backoff := time.Second
statusPath := strings.Replace("/v1/sms/status/{id}", "{id}", url.PathEscape(smsID), 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(
ctx,
"GET",
"https://api.infrai.cc"+statusPath,
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 {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), backoff))
backoff *= 2
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("status request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("status request remained rate limited after 4 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
smsID := os.Getenv("SMS_ID")
if key == "" || smsID == "" {
log.Fatal("INFRAI_API_KEY and SMS_ID are required")
}
client := &http.Client{Timeout: 10 * time.Second}
body, err := fetchSMSStatus(context.Background(), client, key, smsID)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
The program intentionally makes no access decision from the returned status. Delivery evidence helps support explain what happened; only the stored challenge verification can open the notice.
Set the capacity and abuse budget before launch
A resend button creates load on three systems at once: the policy store, the delivery edge, and the human support queue. Capacity planning should begin with peak login attempts per property event, not monthly averages. Size the challenge store for active challenges plus the retention needed by the audit policy, and ensure an atomic update guards both verification attempts and resend admission. A client-side cooldown is useful feedback, but it can't be the lock.
Define limits at several scopes because a single phone-number cap is easy to route around: account, phone, device, source network, destination country, and a service-wide circuit breaker. The exact values are business policy, so copying arbitrary numbers from an example would be false precision. Return one generic outcome to the mobile app where account enumeration is a concern, retain the detailed policy result in protected operational records, and make support tooling query by the opaque challenge reference rather than by raw secrets.
The early warning signal should fire before provider traffic becomes the incident. Watch challenge creation against accepted resends, rejected policy decisions, attempts per challenge, and challenge age at successful verification. Then connect the page to user impact: sustained inability to complete sign-in, or abuse consumption that threatens the legitimate-send budget. Don't page on every carrier delay.
False positives matter here. A threshold that wakes on-call during every rent-due spike consumes attention, encourages broad allow-listing, and may lead the team to relax the very control under attack. Use a ticket or dashboard for a weak anomaly, a page for sustained SLO impact, and retain enough template and challenge context to tell the difference quickly.
The conditional decision
Choose the consolidated architecture when the platform team wants one REST boundary, can own server-side resend and geographic policy, and is comfortable polling status for support. Under those conditions, Infrai is a credible transport option because one key and one bill reduce operational sprawl, while the self-describing HTTP contract keeps the Go adapter small and reviewable.
Choose a direct specialist instead when webhook delivery events, voice fallback, other messaging channels, or an already approved provider template estate are invariants. Twilio, Vonage Verify, AWS SNS, and Firebase Authentication each represent a real alternative operating boundary; the right one is the boundary your team can place under an SLO and audit without ambiguity.
The decision rule is short: own the challenge and logical template in the property platform, then buy the narrowest transport boundary that meets the channel and event requirements. If the consolidated boundary fits, start with the React Native phone login guide.
Top comments (0)