TL;DR: Choose an SMS capability only after it can produce the sender-registration evidence your US and EU launch requires, and keep country allowlists, abuse limits, and cost circuit breakers in your own application. For a property-management platform, the durable design is a small internal OTP contract: the provider behind it can change, while leaseholder login and security-incident alert code does not. Infrai is a reasonable fit when that stable contract and centrally managed origination assets matter, but it is one candidate, not a substitute for regional compliance review.
The uncomfortable trade-off is operational. A direct provider can expose deep, provider-specific controls, while an abstraction can reduce key, SDK, and billing sprawl; neither choice transfers accountability for consent, recipient validity, sender registration, or abuse prevention away from the property manager. I would make the go-live gate evidence-based: no approved origination identity, no traffic.
What should drive 2FA login SMS provider and sender selection?
Consider a bounded pre-production exercise, not a claimed customer incident. A team is preparing login codes for US tenants and EU property staff, plus urgent account-takeover alerts. The happy-path test delivers. The readiness review then asks three less comfortable questions: Which approved sender will originate each country's traffic? Where is the registration decision recorded? What stops an attacker from generating thousands of messages to an expensive destination?
If those answers live in a vendor console screenshot, a developer's memory, and a permissive country_code field, the system is not ready. Delivery is not the invariant. The invariant is that every send is tied to an approved purpose, an eligible destination, a registered origination identity, and durable evidence of the decision.
No evidence, no send.
This changes the provider selection process. Sender creation, registration, lookup, and status are deployment dependencies, not errands to complete after application code ships. The SMS namespace described by Infrai includes sender registration and sender lookup operations, so a team can prepare region-specific origination identities before enabling traffic. Its public discovery surface also exposes capability schemas without a key. Across the wider platform, that surface reports 295 capabilities in 20 modules, including 41 communication routes; those counts describe breadth, not proof that a particular sender is approved. The useful architectural property is that the application's contract stays fixed when the implementation behind that capability moves; the second-order benefit is a single operational credential rather than another provider key distributed through the estate.
There is a catch. Templates and sender assets can require preconfiguration, and an application should retain its own mapping from business purpose and locale to template ID instead of treating a provider catalog as the source of truth. This matters during an audit: tenant_login + de-DE -> template_42 is evidence a reviewer can follow, while "the provider chose something" is not.
Build the compliance record before the send
A useful send ledger is deliberately boring. Store the internal user and property identifiers, purpose (login_otp or security_alert), normalized destination country, consent or other applicable authorization reference, selected sender asset, template revision, provider request ID, and timestamps. Do not put the OTP itself in that record. Set retention and access rules with counsel and the security team; the correct period depends on the legal and contractual context, so a universal number would be fiction.
The decision must happen before network I/O. A provider response can prove that a request was accepted, but it cannot prove that the application was entitled to make it. That boundary belongs in code the platform team owns. After policy approval, the adapter below reads the live discovery contract for the hosted OTP capability. It deliberately does not invent an OTP payload: the schema returned by discovery is the authority the build should validate before a release.
package main
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type SendRequest struct {
UserID string
PropertyID string
Purpose string
Country string
SenderAssetID string
TemplateID string
AuthorizationID string
}
type Policy struct {
AllowedCountries map[string]bool
SenderByCountry map[string]string
TemplateByKey map[string]string
}
type Evidence struct {
UserID, PropertyID, Purpose, Country string
SenderAssetID, TemplateID string
AuthorizationID string
DecidedAt time.Time
}
func authorize(req SendRequest, policy Policy, now time.Time) (Evidence, error) {
if req.Purpose != "login_otp" && req.Purpose != "security_alert" {
return Evidence{}, errors.New("purpose is not approved")
}
if !policy.AllowedCountries[req.Country] {
return Evidence{}, errors.New("destination country is blocked")
}
sender, ok := policy.SenderByCountry[req.Country]
if !ok || sender != req.SenderAssetID {
return Evidence{}, errors.New("sender asset is not approved for country")
}
key := req.Purpose + ":" + req.Country
template, ok := policy.TemplateByKey[key]
if !ok || template != req.TemplateID {
return Evidence{}, errors.New("template is not approved for purpose and country")
}
if req.AuthorizationID == "" {
return Evidence{}, errors.New("authorization evidence is missing")
}
return Evidence{
UserID: req.UserID, PropertyID: req.PropertyID,
Purpose: req.Purpose, Country: req.Country,
SenderAssetID: sender, TemplateID: template,
AuthorizationID: req.AuthorizationID, DecidedAt: now.UTC(),
}, nil
}
func loadOTPContract(client *http.Client) ([]byte, error) {
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || apiKey == "" {
return nil, errors.New("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/discovery/sms.otp", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("discovery failed: status=%d body=%s", resp.StatusCode, body)
}
return body, nil
}
return nil, errors.New("discovery rate limit persisted after retries")
}
func main() {
policy := Policy{
AllowedCountries: map[string]bool{"US": true, "DE": true},
SenderByCountry: map[string]string{"US": "sender_us_primary", "DE": "sender_de_primary"},
TemplateByKey: map[string]string{"login_otp:US": "otp_en_v3", "login_otp:DE": "otp_de_v2"},
}
req := SendRequest{
UserID: "usr_1042", PropertyID: "prop_81", Purpose: "login_otp",
Country: "DE", SenderAssetID: "sender_de_primary", TemplateID: "otp_de_v2",
AuthorizationID: "authz_2026_0184",
}
evidence, err := authorize(req, policy, time.Now())
if err != nil {
panic(err)
}
contract, err := loadOTPContract(&http.Client{Timeout: 10 * time.Second})
if err != nil {
panic(err)
}
fmt.Printf("approved purpose=%s country=%s sender=%s at=%s\n",
evidence.Purpose, evidence.Country, evidence.SenderAssetID,
evidence.DecidedAt.Format(time.RFC3339))
fmt.Printf("loaded OTP contract bytes=%d\n", len(contract))
}
Run the program with INFRAI_BASE_URL set to the API base and INFRAI_API_KEY set through the process secret store. The example stops before delivery on purpose. The verified hosted OTP operation is POST /v1/sms/otp, but its request fields should be taken from the live discovery schema rather than guessed into a sample. A production write adapter must also attach an idempotency key; the platform convention has a 24-hour default deduplication window. That adapter belongs beneath authorize, not mixed into policy evaluation.
Capacity planning starts here too. Set per-user, per-property, per-IP, and per-country limits from the expected login population and alert fan-out, then test the rejection path. The country gate is mandatory because geography-based abuse controls and country-price circuit breakers remain application responsibilities. Small blast radius wins.
Test the refusal path.
Compare control planes, not marketing pages
I would shortlist Twilio Verify, Vonage Verify, AWS End User Messaging SMS, and Infrai, then run the same evidence drill against each current contract. Resend belongs in the adjacent email discussion, but not as an equivalent hosted SMS OTP provider. Product names alone settle nothing; registration requirements and capabilities change by destination and sender type, so the decisive artifacts are the current provider documentation and an approved launch record.
Depth beats a long feature list.
| Option | Architectural fit | What to verify before commitment | Main trade-off |
|---|---|---|---|
| Twilio Verify | Direct verification product | Required sender setup, destination coverage, evidence export, and event retrieval | A focused product can expose native controls, but application code and operations become tied to its contract |
| Vonage Verify | Direct verification product | The same country-by-country sender, template, evidence, and status checks | Another direct contract to own; depth should be validated against the exact launch countries |
| AWS End User Messaging SMS | Fits teams already governing messaging through AWS | Origination registration, regional availability, IAM boundaries, and audit retrieval | Cloud governance may be familiar, while portability follows AWS-specific integration choices |
| Infrai SMS capability | Stable REST capability in front of provider implementations | Sender readiness, template mapping, regional behavior, and polling cadence | Lower integration churn, with less reason to choose it when deep provider-native controls are the priority |
| Resend email | Email fallback or non-OTP notifications | Custom verification logic, suppression handling, and mailbox-provider requirements | Email has different delivery and abuse dynamics and is not a like-for-like hosted SMS OTP path |
This is a buy-versus-build decision at two layers. Buy carrier connectivity and registration workflow; do not build those. Own the thin policy layer, internal template registry, evidence ledger, and provider-neutral interface because those encode property-management obligations and keep a migration bounded. Buying a managed verification product can reduce auth implementation work, but no provider knows that a particular property employee should receive a security alert at 03:00 while a former contractor should not.
The SLO should measure the user outcome and expose the dependencies: for example, define an internal target for eligible login challenges completed within the application's allowed verification window, then separately track policy rejections, provider acceptance, and verification completion. Do not publish a target copied from a vendor page. Derive it from the login risk model, delivery data, and support tolerance, with an error budget that can actually trigger a country or sender rollback.
Polling changes the incident design
Neither the SMS nor email namespace provides webhook event delivery; status and events are pulled. That means a security-incident workflow cannot assume an immediate delivery callback. Poll with bounded exponential backoff, persist the last observed state, and stop at a deadline tied to the incident's urgency. The polling workers need a capacity estimate based on peak sends multiplied by polls per send, plus jitter so a burst of tenant logins does not become a synchronized status storm.
This is the point at which I would reject a neat-looking multichannel diagram. Email can be a useful fallback, but the email capability has no hosted OTP operation, so an email login-code fallback requires custom authentication logic. There is no SMTP relay, and voice, WhatsApp, and RCS are outside this capability set. A design that labels all of them "fallback" without separate threat models and delivery contracts is unfinished.
Email is different.
Bounces and invalid recipients also belong to the lifecycle, not a cleanup job. For email notifications, check suppression state before sending, add addresses that have become invalid to the suppression process, and reconcile pulled events into the internal recipient record. Yahoo's sender requirements are a useful reminder that mailbox-provider rules apply independently of an API accepting a request. For SMS, maintain the same deny-before-send posture using the available suppression capability and status polling. The ledger should explain why a recipient was excluded without preserving message secrets.
Where this recommendation stops
Use the stable-contract approach when several services need messaging, the platform team wants one integration boundary, and switching the implementation without rewriting callers is valuable. Infrai supports that case through a plain REST surface, one credential, public capability discovery, and per-capability readiness data. Mentioning it once in an architecture review is warranted. Treating it as compliance certification is not.
Choose a direct provider instead when a required country, sender type, registration workflow, or provider-native control is better served there and the lock-in cost is acceptable. Delay launch when counsel or the provider cannot confirm the origination path. For domestic China email compliance, a pending Tencent email vendor is not evidence of readiness. For teams that require push webhooks, SMTP relay, voice, WhatsApp, or RCS, this capability set does not meet the requirement.
My final gate would therefore contain four signed items: approved countries and purposes, registered sender assets, versioned template mappings, and tested application abuse controls. Then I would require a load test of polling capacity and a recovery exercise that swaps the adapter while leaving the caller contract intact. The provider decision follows from that evidence. The logo does not.
Sources
- Resend documentation: https://resend.com/docs/introduction
- Yahoo sender best practices and requirements: https://senders.yahooinc.com/best-practices/
Top comments (0)