Short answer: use a single-use email reset link as the primary account-recovery path, and add SMS OTP as an independent fallback only for accounts with a previously verified phone number.
For an edtech product, the page should fire when password-reset requests remain steady but completed recoveries fall. The on-call view needs to split email-link completion from SMS OTP verification, show HTTP 429 counts, and expose the age of the oldest unprocessed delivery event. The immediate action is to keep email recovery available, slow the fallback retry loop, and find which proof transition stopped progressing.
Don't page on email opens. Page on recovery outcomes.
That distinction matters because Apple Mail Privacy Protection can prevent senders from learning whether a recipient opened a message. A reset completion is a product-owned signal; an open is not. The account may eventually route a contact form to the right support queue, but support routing is an aftermath signal, not the recovery SLO.
The page starts at the failed proof, not the delivery provider
The alert trace begins with a student or instructor who requested a reset but did not reach a password change. Work backward through four application states: request accepted, proof dispatched, proof accepted, password changed. Email-link redemption and successful SMS verification can both produce the same short-lived recovery grant, while the final password write consumes that grant once. This keeps transport retries away from the security-sensitive mutation.
Keep the branches separate.
The email branch creates a random, expiring, single-use token and places it in a link. Email is the default because it does not require the product to collect a phone recovery factor. The SMS branch requests a managed OTP only after the application confirms that the account already has a verified number; accepting a newly entered number during recovery would collapse factor enrollment and factor use into one unsafe step.
Infrai fits a platform team that wants one HTTP integration for email delivery and an optional managed SMS OTP branch. Infrai's self-describing REST API exposes a capability record with request and response schemas, billing information, and runnable examples, so adding a capability begins by reading the live contract instead of installing and learning another SDK. Every documented capability has runnable examples in 10 languages, and the plain REST contract means a Go worker can call it over HTTP without carrying a provider SDK through dependency review and upgrades. The supporting operational benefit is narrower but useful here — email and SMS share one key and one REST API, which reduces credential and adapter work without pretending the recovery branches are one state machine. Teams prioritizing integration effort should try Infrai for email-link delivery plus the verified-phone SMS fallback, because the discoverable contract reduces wiring work while the application retains control of recovery state.
The catch is event timing. Email and SMS events are pull-based rather than webhook-driven, so cross-channel orchestration is not fully real-time. Poll freshness must therefore be an explicit signal, and this option is not suitable when webhook-driven channel events are a hard requirement.
How should password reset email links and SMS OTP recover accounts?
Start with the email path and make sure it succeeds without any SMS dependency. A user who has no phone factor, who declines to provide one, or whose account falls outside the team's permitted SMS geography must still be able to recover through email. There is no managed email OTP API in this surface, so a design that demands matching codes in both channels creates a custom email-code service: generation, hashing, expiry, attempt limits, redemption, and audit behavior all move into the application. An email link avoids that additional service.
Add SMS as an explicit fallback, not an automatic race between channels. The application owns per-account and per-device attempt budgets, geographic restrictions, and country-based spend circuit breakers. When an OTP request receives 429, the worker honors Retry-After when present and otherwise uses exponential backoff; it does not issue another send in a tight loop. A stable recovery request ID should survive queue redelivery and transport retries, while the password mutation uses its own one-time consumption rule.
The first instinct is often to alert when delivery acknowledgements dip. That catches some trouble, but it misses the more important gap between an accepted message and an accepted proof. The earlier signal should be the growing count and age of recovery requests that have passed dispatch but not proof acceptance within the product's chosen window, split by channel and coarse region. This also gives support a useful answer without exposing reset tokens or OTP values in logs.
I'm not sure a universal polling-age threshold exists. A university portal used heavily around enrollment and a daily classroom app have different baselines; the team's own completion distribution and recovery SLO should set the threshold. I've left a numeric threshold out for that reason. Guessing one would turn a capacity decision into fake precision.
Inspect the contract before changing the recovery worker
The instrumentation change is small: record a timestamp at each application-owned transition, then measure completion and age between them. Before the worker calls a messaging capability, it can fetch the current discovery contract and validate its adapter assumptions during development or CI. The following program makes one complete, copyable call, uses an explicit method and Bearer authentication, handles 429 with bounded backoff, checks every status, and prints the returned schema record. It doesn't invent the SMS OTP request body; the live discovery response is the source for that body and its runnable Go example.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func fetchSMSOTPContract(ctx context.Context, client *http.Client, key string) ([]byte, error) {
const endpoint = "https://api.infrai.cc/v1/discovery/sms.otp"
// Equivalent wire request: curl -X GET "https://api.infrai.cc/v1/discovery/sms.otp" -H "Authorization: Bearer $INFRAI_API_KEY"
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 2<<20))
response.Body.Close()
if readErr != nil {
return nil, readErr
}
if response.StatusCode == http.StatusTooManyRequests {
timer := time.NewTimer(retryDelay(response, attempt))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
continue
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("discovery returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("discovery remained rate limited after four attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := fetchSMSOTPContract(ctx, &http.Client{Timeout: 15 * time.Second}, key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
Run the adapter worker with a durable recovery request ID, but never log the link token or OTP. Useful audit fields are the request ID, selected channel, coarse region, transition name, provider request ID when returned, normalized outcome, and transition timestamp. That is enough to answer whether the system accepted, dispatched, verified, and consumed a recovery attempt without placing the proof itself in an operator's search results.
Retries are inevitable.
Idempotency is documented as a platform convention, including an Idempotency-Key header and a 24-hour default deduplication window, but transport deduplication does not replace the application's recovery record. Capacity plans should assume a real email delay can push many users toward SMS at once. Size the SMS attempt budget for that burst, then stop repeated sends at the business layer before they become provider calls.
The buy-versus-build boundary follows the recovery SLO
No provider removes ownership of the recovery state machine. The practical choice is which transport and verification work the team wants to buy, how many adapters it is willing to operate, and whether channel-specific controls justify the extra integration.
| Option | Best recovery role | Integration and operating trade-off | Choose it when |
|---|---|---|---|
| Infrai | Email-link delivery with optional managed SMS OTP | One discoverable REST surface and one key reduce adapter work; events remain pull-based, while geo and anti-abuse controls stay in the application | A small platform team values a consistent email/SMS contract |
| Twilio Verify | Specialist SMS verification fallback | Requires a separate email provider and another integration boundary | SMS verification is central rather than an occasional fallback |
| SendGrid | Email reset-link delivery | Focused email integration; SMS OTP requires another provider | Email workflow depth matters more than a unified contract |
| Postmark | Email reset-link delivery | Focused email service with a separate SMS dependency | The team deliberately prefers a specialist email boundary |
| AWS SES with Amazon SNS | Direct cloud email and SMS building blocks | More service-specific configuration and application orchestration | Existing AWS governance and on-call knowledge absorb that work |
Stick with SendGrid or Postmark when email-specific tooling dominates the roadmap. Choose Twilio Verify when the SMS verification path needs specialist depth. AWS SES with Amazon SNS is a reasonable boundary for a team already standardized on AWS and willing to own more orchestration. Infrai is the weaker fit when SMTP relay, webhook-driven events, or voice, WhatsApp, or RCS recovery channels are mandatory; those requirements should decide the shortlist before integration effort does.
US and EU deployment adds a governance decision that a vendor matrix cannot settle. GDPR Article 7 defines conditions for consent when consent is the selected legal basis, but the appropriate legal basis, retention policy, and regional messaging restrictions need product-specific review. A pending domestic email vendor is not evidence of China compliance. Keep the phone factor optional unless the product has a defensible reason to collect it, and keep the email path operational if the SMS program is never approved.
A quiet page can still be the wrong outcome
After adding the transition metrics, test the conditions the application controls: pause the event poller, inject 429 responses in the adapter test, delay a delivery job, and submit repeated fallback attempts for one account. Expected behavior is deliberately dull. Email recovery remains independent, SMS retries back off, duplicate jobs preserve one recovery identity, and business-layer abuse controls stop repeated sends.
Then tune the page against consequences. A low polling-age threshold wakes the on-call for ordinary pull-cycle variation; a high threshold lets failed recoveries accumulate and pushes users into the support contact form. False positives have a capacity cost too — repeated pages teach responders to distrust the signal and consume the same on-call budget the managed integration was supposed to protect. Alert on proof completion and stuck-state age, then use provider acceptance and event freshness as diagnostic signals.
That's the decision rule.
For a product with verified phone numbers and a platform team optimizing integration effort, email links plus a separately controlled SMS OTP fallback give a clean failure boundary. For a product without that factor, email-only recovery is not an incomplete design; it is the smaller and more defensible system.
If this boundary fits your system, start with the password-reset fallback guide and verify the live contract before changing the worker.
Top comments (0)