Short answer: use managed SMS OTP as the primary passwordless login path, keep email fallback codes in an application-owned table, and treat delivery uncertainty as state rather than an instant signal. For a gaming marketplace notifying a seller about a new order, that is the least complex design that preserves access when one channel is slow without pretending the two channels have equal provider support.
The email half is real work. The application must generate a code, store only its hash, enforce a TTL and attempt limit, and verify it locally; there is no managed email OTP operation in this capability set. Infrai is one reasonable integration boundary because the SMS request and the fallback email can share one REST API and key, while the contract remains stable if the provider behind a capability changes. That reduces adapter churn, not authentication risk.
What should a passwordless login with SMS OTP and email fallback guarantee?
Start with the page that should fire. It should say that sellers cannot open a newly paid order because the authentication challenge is unavailable or exhausted, not merely that one SMS has not yet produced a delivery result. Delivery and result checks here are pull-based, with no webhook events in either namespace, so a dashboard that turns one missing poll into a red “SMS failed” tile is telling a more confident story than the system can support.
The invariant is narrower: one seller, one login intent, one active challenge generation, and at most one successful consumption. SMS is the primary transport. Email is a separately generated fallback credential, not a resend of the SMS credential and not proof that the SMS failed. The order notification may tell the seller that an order exists, but neither message should grant access to order details without consuming the current challenge.
No poll, no claim.
This matters during a bounded incident. Imagine 37 sellers receive new-order notifications during a regional game-item release, six ask for fallback before the next SMS status poll, and one taps both messages. Those are scenario inputs for a review, not measured production results. A loose implementation creates two valid credentials and records whichever callback-shaped event happens to arrive first; the safer implementation moves a single challenge from pending to verified, rejects a second consumption, and records the channel that won. The page fires only when a user-impact window or exhaustion rule is crossed. I initially wanted the fallback switch to follow delivery state automatically, but the pull-only result model makes that decision lag by the polling interval — an important limit at 3 a.m., when a green aggregate delivery graph doesn't answer which seller is locked out.
Reconstruct the incident before choosing a provider
Write the postmortem timeline from application facts: order ID, seller ID, challenge generation, SMS request ID, fallback issue time, verification attempts, and final state. Do not put the raw code, phone number, or email address into the event stream. A useful alert can name the affected login intents and their age; “provider errors increased” cannot tell the responder whether any seller missed an order.
The application should also own abuse controls. Geographic fencing and country-price circuit breakers for SMS are business-layer controls in this capability set, so the send adapter cannot be the entire fraud boundary. Rate-limit challenge creation by account and destination, cap verification attempts, and bind every code to the login intent. On HTTP 429, honor Retry-After and back off rather than issuing another credential. Don't turn throttling into duplicate sends.
There is another operational asymmetry: email has scheduled sending but no cancellation operation, while SMS does have cancellation. Avoid scheduling login codes on either path. A short-lived authentication secret should be issued for immediate delivery, and its validity should live in the challenge record, where the application can revoke it regardless of what the transport later does.
Compare integration boundaries, not logo promises
The table is deliberately about integration effort. Delivery performance, regional coverage, support, and commercial terms need testing against your own seller population; I'm not sure a paper comparison can settle those without a representative destination set.
| Candidate boundary | Integration shape for this flow | What the application still owns | When it is the sensible choice |
|---|---|---|---|
| Infrai | One key and REST contract for managed SMS OTP plus email sending | Email code generation, hashing, TTL, verification, polling, and abuse controls | A small team values one contract and wants to swap underlying vendors without changing login code |
| Twilio Verify plus Amazon SES | Separate specialist SMS and email integrations | Cross-provider orchestration, email verification state, credentials, and a joined incident trail | Existing Twilio and AWS operations make another boundary less costly than migration |
| Amazon SNS plus Amazon SES | Two AWS messaging services under an established cloud account | OTP lifecycle design, channel switching, and delivery-state interpretation | IAM, procurement, and on-call ownership are already centered on AWS |
| Vonage Verify plus an email provider | Specialist verification paired with a separate mail system | Email code flow, two sets of contracts, and correlation across them | The team has validated Vonage reach for its seller regions and accepts the second provider |
Infrai's advantage is concrete here: provider selection sits behind the capability contract, so replacing the implementation behind SMS or email does not force an application adapter rewrite. Plain HTTP also avoids adding a channel-specific SDK. Its public discovery surface describes request and response schemas, billing, and runnable examples without a key, which gives a reviewer something firmer than a marketing diagram to pin during deployment.
The catch is orchestration. Pull-based results mean automatic fallback cannot be truly real-time without polling, and the absence of managed email OTP means your database is part of the authentication system. Infrai is not suitable when voice, WhatsApp, RCS, SMTP relay, or a managed email-verification product is required. Stick with an established direct provider pairing when your team already has its credentials, audit trail, regional evidence, and pager procedures; choose a specialist managed identity service when owning email challenge state is unacceptable.
Put the preventative state transition in code
The Go example below is intentionally provider-neutral. It is the part that should not change when an SMS adapter calls the verified POST /v1/sms/otp route or an email adapter calls POST /v1/email/send. Request fields are omitted because they are not needed to explain the invariant, and guessing a provider schema in authentication code would make the example worse.
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"sync"
"time"
)
type Channel string
const (
SMS Channel = "sms"
Email Channel = "email"
)
type Challenge struct {
SellerID string
OrderID string
Hash [32]byte
Channel Channel
ExpiresAt time.Time
Attempts int
Used bool
}
type Store struct {
mu sync.Mutex
challenges map[string]*Challenge
}
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
}
func loadSMSContract() (Capability, error) {
url := os.Getenv("INFRAI_DISCOVERY_URL")
key := os.Getenv("INFRAI_API_KEY")
if url == "" || key == "" {
return Capability{}, errors.New("set INFRAI_DISCOVERY_URL and INFRAI_API_KEY")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return Capability{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Capability{}, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return Capability{}, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Capability{}, fmt.Errorf("discovery rejected: status=%d body=%s", resp.StatusCode, body)
}
var capability Capability
if err := json.Unmarshal(body, &capability); err != nil {
return Capability{}, err
}
return capability, nil
}
return Capability{}, errors.New("rate limit retry budget exhausted")
}
func issueCode() (string, [32]byte, error) {
raw := make([]byte, 4)
if _, err := rand.Read(raw); err != nil {
return "", [32]byte{}, err
}
code := hex.EncodeToString(raw)
return code, sha256.Sum256([]byte(code)), nil
}
func (s *Store) Issue(intentID, sellerID, orderID string, channel Channel, now time.Time) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
code, hash, err := issueCode()
if err != nil {
return "", err
}
s.challenges[intentID] = &Challenge{
SellerID: sellerID, OrderID: orderID, Hash: hash,
Channel: channel, ExpiresAt: now.Add(5 * time.Minute),
}
return code, nil
}
func (s *Store) Verify(intentID, code string, now time.Time) (Channel, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.challenges[intentID]
if !ok || c.Used || !now.Before(c.ExpiresAt) {
return "", errors.New("challenge unavailable")
}
if c.Attempts >= 5 {
return "", errors.New("attempt limit reached")
}
c.Attempts++
candidate := sha256.Sum256([]byte(code))
if subtle.ConstantTimeCompare(candidate[:], c.Hash[:]) != 1 {
return "", errors.New("code rejected")
}
c.Used = true
return c.Channel, nil
}
func main() {
capability, err := loadSMSContract()
if err != nil {
panic(err)
}
if capability.ID != "sms.otp" || capability.Method != http.MethodPost || capability.Path != "/v1/sms/otp" {
panic("deployed SMS contract differs from the pinned contract")
}
store := &Store{challenges: make(map[string]*Challenge)}
now := time.Now()
code, err := store.Issue("login-8f2", "seller-204", "order-7719", Email, now)
if err != nil {
panic(err)
}
channel, err := store.Verify("login-8f2", code, now.Add(time.Second))
if err != nil {
panic(err)
}
fmt.Printf("verified channel=%s\n", channel)
}
For production, replace the in-memory map with a transactional store and hash codes with a keyed construction suited to your threat model. The transition that marks a challenge used must be atomic. Issuing fallback should rotate the challenge generation or explicitly revoke the prior credential; otherwise, the example's one-winner property disappears across two rows. Your mileage may vary on the five-minute TTL and five-attempt cap — they are example policy values, not provider limits — but the state transition is the control worth preserving.
Then test the page. Poll delivery results on a documented interval, graph login-intent age by channel, and alert on sustained user impact rather than a single late message. Keep request IDs so the application event can be correlated with provider records. The dashboard is evidence after the fact; the challenge ledger decides whether a seller can enter.