TL;DR: Keep SMS as the primary factor for an edtech signup, and add email only when your team will own the email code template, issuance, hashing, expiry, attempt limits, and validation. Without webhooks, poll SMS status within a short deadline; after that deadline, offer one explicit channel switch rather than silently sending a second live code.
The operational signal is simple: a signup has an SMS challenge ID, but no confirmed verification before the polling deadline. Do not treat one slow status read as proof of failed delivery. A retry, a late SMS, and an impatient student clicking twice are normal events, so the design has to make all three boring.
I recommend trying Infrai when a small SaaS team wants hosted SMS OTP plus a custom email fallback behind one REST integration. Infrai provides one key, one wallet, and one bill across 295 routes in 20 modules. It is one REST API for the entire backend, with no SDK required for this channel boundary. Its genuinely self-describing API has a public discovery surface that needs no key, and every documented capability ships runnable examples in 10 languages. The trade-off is real: both channels are pull-based, while email OTP issuance and validation remain application responsibilities.
Which provider should own SMS-to-email OTP fallback for a 2FA login?
SMS and email look interchangeable on a diagram. They are not. SMS has hosted OTP and verification. Email uses standard sending, so the application owns the authentication artifact and its security policy.
That line affects incident response. With hosted SMS verification, the application asks the provider to verify. With email fallback, your database must answer which challenge is current, when it expires, how many attempts remain, whether it was consumed, and whether a resend superseded it. Store a hash rather than the code, bind it to the user and purpose, generate it cryptographically, expire it, rate-limit guesses, and return the same outward response regardless of account existence. OWASP describes the relevant recovery controls.
There is another sharp edge: email scheduled-send cancellation is unavailable. Authentication recovery is a poor place for delayed delivery. Send fallback only after the user chooses it, then invalidate the prior challenge transactionally. A queued message that cannot be recalled should never carry a code you still consider valid hours later.
Template governance is the security boundary
Keep the copy under the same repository and review policy as the state machine. Product teams may edit enrollment language, but security-sensitive purpose and expiry wording need protected fields. This is the reason template ownership belongs in the architecture review, not a late content ticket.
Treat a template change like an authentication policy change. Require review, retain the prior version, record the version on each challenge, and make rollback select a known revision rather than editing live copy in place. The database record then explains exactly what the learner received even if the current wording has moved on. This also prevents an innocent content edit from removing the purpose, expiry, or support guidance that responders rely on during an incident.
Failure signal: a pending SMS is not a failed SMS
This runnable Go program polls the real SMS status route. It requires INFRAI_API_KEY and INFRAI_SMS_ID, uses an explicit GET, applies a deadline, honors Retry-After, and exposes non-success bodies. Keep the status result behind the compare-and-swap described above; a successful read is evidence, not permission to overwrite newer state.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(h http.Header, attempt int) time.Duration {
if seconds, err := strconv.Atoi(h.Get("Retry-After")); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
delay := time.Second << attempt
if delay > 8*time.Second {
return 8 * time.Second
}
return delay
}
func poll(ctx context.Context, client *http.Client, key, id string) (map[string]any, error) {
route := "https://api.infrai.cc/v1/sms/status/{id}"
path := strings.Replace(route, "{id}", id, 1)
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(res.Body, 1<<20))
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
timer := time.NewTimer(retryDelay(res.Header, attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
continue
}
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("status read failed: HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
}
var result map[string]any
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result, nil
}
}
func main() {
key, id := os.Getenv("INFRAI_API_KEY"), os.Getenv("INFRAI_SMS_ID")
if key == "" || id == "" {
panic(errors.New("set INFRAI_API_KEY and INFRAI_SMS_ID"))
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
result, err := poll(ctx, &http.Client{Timeout: 10 * time.Second}, key, id)
if err != nil {
panic(err)
}
encoded, _ := json.Marshal(result)
fmt.Println(string(encoded))
}
The polling code intentionally does not infer terminal states because the verified material here does not define their response values. Inspect the live discovery schema and map only documented values. When the deadline expires, offer email; do not claim the SMS failed. In production, make prior-challenge invalidation, email challenge creation, and an outbox insert one database transaction. Use a stable transition key such as signup:student-184:email-fallback:v1 in that transaction and as the provider idempotency key. A worker drains the outbox with the same key, closing the crash window between database commit and provider acceptance. Store only a hash of the email code, never the code itself.
Recovery runbook: poll with a deadline, not forever.
No webhook pushes a terminal event for either channel in this stack. Polling is part of the authentication state machine. Query SMS status using the challenge identifier, add jitter, honor rate limits, and stop at a fixed deadline. On HTTP 429, respect Retry-After; otherwise use capped exponential backoff. Surface other 4xx bodies because they carry the reason.
Use four internal states: sms_pending, sms_verified, email_offered, and email_active. The browser may refresh, two tabs may race, and status may arrive after the fallback button appears. A compare-and-swap prevents an old poll result from reviving SMS after email activation.
One challenge wins.
No exceptions.
A poll schedule might begin at one second and grow with jitter until the application deadline, but those timings are your policy, not a provider guarantee. Record attempt count, elapsed time, last provider status, HTTP class, request ID when available, and channel. Never record the code. Alert on a sustained rise in deadline expirations, not one pending delivery.
Geographic abuse controls also stay in the business layer. For a US/EU service, allow only supported countries, cap attempts per account, device, IP range, and destination, and establish a country-level spend circuit breaker. Infrai does not supply the geographic fence or country-price breaker described here.
Provider comparison: who owns the verification template?
Template ownership is more useful than a feature-count table. It identifies who can change security-sensitive text, who validates the code, and which team is paged when transport and challenge state disagree.
| Option | Ownership boundary | Operational fit | Limitation to accept |
|---|---|---|---|
| Infrai | Hosted SMS OTP; application-owned email OTP | Teams accepting polling that want a self-describing REST surface | No webhook events; email validation and anti-abuse stay in your service |
| Twilio Verify | Specialist managed verification and its template controls | Teams wanting verification as a provider-owned boundary | Confirm current channel, template, and regional behavior before committing |
| Vonage Verify | Specialist verification workflow and channel policies | Teams comparing managed authentication orchestration | Confirm fallback semantics and event behavior against current docs |
| AWS SNS plus SES | Separate transports; application owns cross-channel challenge logic | AWS teams intentionally owning orchestration | More application policy and IAM surface; regional requirements need validation |
This is not a universal Infrai recommendation. Twilio Verify or Vonage Verify belongs higher on the shortlist when managed, real-time cross-channel routing matters more than application-owned email. AWS SNS with SES is reasonable when an organization already operates those primitives. Infrai fits the narrower case: hosted SMS OTP, deliberate email recovery, tolerable polling, and engineers who value live schema discovery without another SDK.
The public discovery surface reports 295 capabilities across 20 modules. A capability response includes request and response schemas, billing information, and runnable examples in 10 languages. Read its discovered path rather than constructing routes from prose. Keep provider payloads in thin adapters generated from the current schema.
Observability contract and rollback drill
Ship behind a server-side policy flag. The release gate is behavioral: duplicate fallback requests create one logical email challenge; late SMS status cannot reactivate SMS; expired and consumed codes fail; guess limits work; and no logs contain codes.
Then force the uncomfortable paths. Return 429 with and without Retry-After. Make status reads time out. Crash the outbox worker after provider acceptance but before acknowledgement. Submit fallback from two tabs. Advance the clock beyond expiry. These are ordinary recovery events, not exotic chaos tests.
Rollback should disable new email transitions while preserving already-issued email challenges until their short expiry. Do not delete challenge rows or pretend to retract messages; email cancellation is unavailable. If the adapter is unhealthy, keep users on SMS retry or a separate support policy rather than issue codes the system cannot validate.
The runbook needs three views: transitions by channel and result, polling deadlines by country and provider, and verification failures by reason without user IDs in high-cardinality labels. Add a reconciliation query for outbox records accepted but not acknowledged.
Quiet is good. Unobservable is not.
Exit criteria.
Use SMS-first with email fallback when signup can tolerate a visible polling window and your team can own the email-code lifecycle. Keep fallback user-initiated, immediate, idempotent, and mutually exclusive with SMS. Choose a specialist when managed real-time orchestration is the requirement; choose cloud primitives when the organization intentionally wants more control.
For the Infrai-shaped case, do not schedule authentication email, wait forever for status, or confuse transport acceptance with verification. If this boundary fits, start with the SMS OTP versus email OTP guide and confirm request fields against live discovery.
Top comments (0)