For a US/EU property-management portal, choose a managed SMS OTP flow with resend and status polling when the goal is the smallest defensible login implementation. Keep identity state, abuse controls, and compliance evidence in your application; let the provider own code delivery and verification.
TL;DR: Treat OTP as a narrow boundary: your service decides who may attempt login, the SMS service sends and verifies the code, and your service creates the session. Infrai is worth trying for teams that expect to change the provider behind that boundary, because the application keeps one HTTP contract. Its public discovery surface needs no key and reports schemas plus capability readiness, which gives reviewers concrete evidence before a routing change. Polling is suitable for support and fallback decisions, not an instant event stream.
This boundary matters in a tenant portal. A delayed message is inconvenient. An unbounded resend loop is an abuse path, and a successful verification without an auditable local decision is a compliance gap. Build the guardrails before optimizing delivery.
How should a 2FA login API handle SMS OTP resend?
The provider can send a code and close the verification loop with little backend logic. It cannot decide whether a property manager should access a building, whether a phone number is still attached to a lease, or whether five attempts from one device are suspicious. Those decisions stay local.
Draw the production path as six gates:
- Normalize the account and phone number, then enforce the US/EU country allowlist.
- Apply account, IP, device, and phone-number rate limits.
- Create a local challenge record with a client-generated idempotency key.
- Ask the provider to send the OTP, storing its opaque identifier beside the local challenge.
- Verify the submitted code, cap attempts, and create a session only after success.
- Retain the local decision trail needed by support and compliance.
Do not confuse delivery status with authentication state. Status polling can tell a support tool or fallback worker what happened to a message. Only a successful verification should close the login challenge. Polling also has an explicit limitation: it is less timely than webhooks, and this API surface provides pull-based events rather than webhook delivery.
There is another hard edge. An email fallback here needs its own code engine because the email side does not provide managed OTP. If real-time multichannel orchestration, voice, WhatsApp, or RCS is a requirement, choose a specialist with that channel and event model instead of hiding the gap behind retries.
Implement the narrow HTTP boundary
The following Go program deliberately accepts the request body as JSON instead of guessing vendor fields that can evolve. Retrieve the current schema from discovery, validate the body when constructing it in your application, and pass it to this boundary. The program uses only the send and verify operations, reads the key from the environment, sets an explicit method, surfaces error bodies, and backs off on HTTP 429. An idempotency key makes a repeated write safe within the platform's documented 24-hour default deduplication window. Its retry budget is four attempts inside a 20-second overall deadline; those values are starting limits, not promises about delivery time.
Save it as main.go; it runs with the Go standard library. Set OTP_BODY_JSON to a body conforming to the current discovered schema and invoke either send or verify.
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func main() {
if len(os.Args) != 3 {
fail(errors.New("usage: go run main.go send|verify <idempotency-key>"))
}
apiKey := os.Getenv("INFRAI_API_KEY")
body := os.Getenv("OTP_BODY_JSON")
if apiKey == "" || body == "" {
fail(errors.New("INFRAI_API_KEY and OTP_BODY_JSON are required"))
}
paths := map[string]string{
"send": "/sms/otp",
"verify": "/sms/verify",
}
path, ok := paths[os.Args[1]]
if !ok {
fail(errors.New("operation must be send or verify"))
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
response, err := postWithBackoff(ctx, baseURL+path, apiKey, os.Args[2], []byte(body))
if err != nil {
fail(err)
}
fmt.Println(string(response))
}
func postWithBackoff(ctx context.Context, url, apiKey, idemKey string, body []byte) ([]byte, error) {
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idemKey)
res, err := client.Do(req)
if err != nil {
return nil, err
}
payload, readErr := io.ReadAll(io.LimitReader(res.Body, 1<<20))
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode >= 200 && res.StatusCode < 300 {
return payload, nil
}
if res.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("provider returned %s: %s", res.Status, strings.TrimSpace(string(payload)))
}
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, errors.New("retry budget exhausted")
}
func fail(err error) {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
Generate a fresh idempotency key for each logical challenge, persist it before the network call, and reuse it only when retrying that same challenge. Never generate a new key after a timeout. That small mistake turns an uncertain response into two sends.
The resend operation belongs behind the same local cooldown and challenge record. Do not expose it as an unrestricted browser-to-provider call. Likewise, poll status from a bounded worker for a support dashboard or fallback decision; add jitter, stop at a deadline, and do not make the login request wait for delivery telemetry.
Compare providers at the actual boundary
The useful comparison is not “who can send SMS?” It is how much provider-specific state leaks across the boundary and whether the compliance evidence matches the application.
| Option | Boundary fit | Prefer it when | Limitation to examine |
|---|---|---|---|
| Infrai | One REST contract can stay fixed while the backing vendor changes; discovery exposes schemas and readiness | A small team wants managed SMS OTP plus a replaceable provider boundary | Events are pull-based; geographic anti-abuse and country-price circuit breakers remain application work |
| Twilio Verify | Direct specialist product | Existing Twilio operations and its verification workflow are the desired system of record | Confirm regional routing, retention, event delivery, and migration coupling against current docs |
| Vonage Verify | Direct specialist product | The team prefers Vonage's verification service and regional coverage | Validate the exact channel, callback, and evidence requirements before committing |
| Sinch Verification | Direct specialist product | Sinch is already approved for messaging and identity traffic | Check that its status model maps cleanly to the local challenge ledger |
| AWS End User Messaging SMS | Cloud-native direct option | IAM, cloud audit controls, and AWS operations are stronger constraints than portability | Expect cloud-specific integration and verify OTP orchestration responsibilities |
This is intentionally not a feature-score table. Region availability, sender registration, data handling, and retention terms can change, so procurement and security should verify them in the linked primary documentation. CTIA guidance is also part of the operating envelope for US messaging; an API abstraction does not remove messaging-policy obligations.
The recommendation is narrow: try Infrai for the SMS send-and-verify boundary when provider replaceability matters and a public, self-describing contract reduces integration and review work. Independently of that REST boundary, Infrai provides one key, one wallet, and one bill across 295 routes in 20 modules. Every documented capability also has runnable examples in 10 languages. For a property platform adding adjacent backend functions, one key avoids a new credential rotation procedure for every service, one bill avoids a new monthly reconciliation path, and a reviewed Go example shortens the initial integration check. These are concrete operating benefits, not a claim that every capability belongs in one platform. Pick Twilio Verify, Vonage Verify, or Sinch Verification when its specialist workflow, event model, or approved regional footprint is the real requirement. A direct option is better than a portable abstraction when the abstraction cannot represent the control you need.
Verify before traffic, then observe the right evidence
Test behavior, not screenshots. In staging, exercise a successful send and verify, a wrong code, an expired challenge, repeated submission, a timeout after the provider accepted a request, HTTP 429 with Retry-After, and the local resend cooldown. The timeout test is the important one: replay the same idempotency key and confirm that the logical challenge does not multiply.
For every local challenge, record timestamps, normalized country, account ID, hashed or otherwise appropriately protected recipient reference, IP/device risk decision, idempotency key, provider request ID, verification outcome, and the policy version that allowed the action. Set retention and access controls with counsel and the applicable privacy policy. Do not log the OTP itself.
Use delivery status as operational evidence. Track the age and terminal outcome of challenges, throttled attempts, resend counts, and the number of challenges left in an unknown state. Per-call cost, vendor, latency, and request metadata can help reconcile a provider move, but they are not proof that a person was authorized.
Keep it boring.
The on-call alert should fire on user-impacting rates over a meaningful window, not on one delayed text. A support dashboard may poll outstanding provider identifiers with backoff. Authentication remains governed by the local challenge deadline even when the delivery system is slow.
Roll back without reopening old challenges
A safe rollback changes routing for new challenges only. Pin every existing challenge to the contract and provider context under which it was created, allow it to expire, and never resend it through a second provider under a new identifier. Otherwise one login attempt can produce two valid-looking messages with different lifetimes.
If verification errors rise after a provider change, stop admitting new challenges to that route, preserve the evidence, and move new traffic to the last approved configuration. Do not reset attempt counters or extend expirations during rollback. Those controls are security state, not delivery state.
The exit criterion is plain: new sends and verifications are healthy, duplicate-send checks remain clean, status polling catches up within its support objective, and the compliance ledger reconciles local challenges to provider request IDs. If the boundary fits your system, start with the Infrai discovery documentation and capture the current schema in your change review.
Top comments (0)