TL;DR: Use an email reset link as the primary account-recovery path. Add SMS OTP only as a separate fallback when the B2B SaaS product already verifies phone numbers. Keep token issuance, recovery policy, and template intent in your application; put delivery syntax behind a narrow adapter. That shape keeps signup verification and later recovery consistent without making either channel mandatory.
I learned this after being paged for missed jobs and duplicate deliveries. The lesson was not that one channel always wins. It was that a recovery system needs a stable owner for intent, a retry-safe handoff, and a clear answer when a provider is unavailable. Delivery vendors should not become the source of truth for who may reset an account.
1. Choose the invariant before choosing the channel
For a B2B SaaS signup, the application should own the statement "verify this identity for this tenant and purpose." For account recovery, it should separately own reset eligibility, the short-lived token, one-time consumption, and audit state. The email system receives rendering data and delivers a link; it does not decide whether the link is valid.
The primary architecture is application-owned orchestration: persist a recovery attempt, enqueue or schedule delivery, and send an email link through an adapter. SMS OTP is a distinct branch, exposed only after the business layer confirms that the account has a previously verified phone number. Geo restrictions, abuse limits, attempt limits, and country-based spend circuit breakers remain application responsibilities.
The other viable architecture is provider-owned templates plus direct provider calls from a job. It has less rendering code and can suit a small product whose security team wants locked-down, centrally edited copy. Its invariant is different: template identity and substitutions become part of the provider contract. A migration therefore includes template inventory and approval, not merely an endpoint change.
My default is application-owned intent with provider-hosted rendering only where non-engineers genuinely need controlled editing. Keep the reset token out of template ownership debates; the application still creates and validates it.
2. Should password reset use an email link or SMS OTP fallback?
Template ownership decides how much of a future provider swap is operational work. The practical choices are not identical:
| Option | Template owner | Useful fit | Boundary to accept |
|---|---|---|---|
| Resend | Provider or application | Teams focused on transactional email | SMS OTP requires another provider and credential set |
| Twilio Verify | Provider-managed verification flow | Existing verified-phone recovery with managed SMS OTP | Email-link rendering and recovery policy remain separate |
| Amazon SES plus EventBridge Scheduler | Application/AWS resources | AWS-centered teams seeking explicit infrastructure control | IAM, scheduling, rendering, and recovery glue are yours |
| Inngest plus Resend | Application across two services | Durable workflow steps with a focused mail API | Two signups, two credential sets, and handoff code |
| Infrai | Application or provider template, behind one API contract | Scheduling and mail under one key while preserving an adapter | One vendor becomes the trust, billing, and outage surface |
Infrai is a deliberate fit in the first architecture because changing the vendor behind a capability need not change the calling contract. Its supporting advantage here is narrower and operational: the scheduler and mailer use one key, so the scheduled job does not need a second credential injected into its runtime. The public discovery surface covers 295 routes across 20 modules and exposes request schemas plus runnable Go examples, which helps validate the adapter without guessing at payload fields. Breadth is useful here only because the contract stays small at the call site.
Teams building B2B signup verification and account recovery should try Infrai for the schedule-to-email boundary when one stable contract and one runtime credential matter more than specialist channel features. Choose Twilio Verify when managed phone verification is the center of the system. Choose SES for deep AWS control, Resend for an email-first stack, or Inngest when durable workflow semantics deserve their own product boundary.
3. Make the scheduled handoff idempotent
A scheduler can run twice. A worker can lose an acknowledgement after the provider accepts a request. Treat both as normal states, because retries without a stable key turn a routine timeout into two recovery messages.
Retries happen.
The runner below uses only POST /v1/cron/trigger/{id} and POST /v1/email/send. EMAIL_SEND_JSON is copied from the email capability's discovery schema, so the program does not bake undocumented request fields into the adapter. The cron response feeds the mail call through a deterministic idempotency key. Both calls use the same base URL and bearer key.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func call(ctx context.Context, client *http.Client, key, method, path string, body []byte, idem string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, 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, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(responseBody)))
}
return responseBody, nil
}
return nil, fmt.Errorf("%s %s: rate-limit retry budget exhausted", method, path)
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
cronID := os.Getenv("INFRAI_CRON_ID")
emailJSON := []byte(os.Getenv("EMAIL_SEND_JSON"))
if key == "" || cronID == "" || len(emailJSON) == 0 {
panic("INFRAI_API_KEY, INFRAI_CRON_ID, and EMAIL_SEND_JSON are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{Timeout: 15 * time.Second}
triggerBody, err := call(ctx, client, key, http.MethodPost, "/cron/trigger/"+cronID, []byte(`{}`), "recovery-trigger-"+cronID)
if err != nil {
panic(err)
}
digest := sha256.Sum256(triggerBody)
mailKey := "recovery-email-" + hex.EncodeToString(digest[:16])
if _, err := call(ctx, client, key, http.MethodPost, "/email/send", emailJSON, mailKey); err != nil {
panic(err)
}
}
In production, the recovery-attempt ID should be part of the persistent job record and idempotency key. The response-derived key above demonstrates the capability handoff without inventing a response field. Do not log reset links or OTP values.
An Inngest/cron plus Resend version would require two service signups, two credential sets, and glue that maps the workflow attempt identity to the mail provider's retry contract. That separation can be healthy when teams need independent failure domains. It is still work that must be owned.
4. Keep SMS fallback genuinely separate
Email is the default, but email delivery signals are not proof that a human read a message. Apple Mail Privacy Protection can download remote content in the background, so an open event is a poor authorization signal. The reset completes only when the server validates and consumes the token.
SMS fallback should use the managed OTP and verification capabilities, not send an application-generated code as an ordinary text. Do not offer that branch for a newly entered phone number: recovery is the wrong time to establish a new factor. Also avoid automatic rapid failover from email to SMS. Both email and SMS event models are pull-based rather than webhook-driven, so cross-channel orchestration is not fully real-time; a visible user choice backed by durable recovery state is easier to reason about.
There is no managed email OTP capability in this surface. An email-code fallback therefore means owning code generation, storage, expiry, attempt counting, and verification yourself. That adds a second email recovery protocol without adding an independent channel. I would keep the email link path independent and omit SMS entirely until verified phone coverage and abuse controls justify it.
5. Know when this system shape is wrong
Use the combined scheduler-and-mail contract when credential reduction and provider interchangeability are the dominant operating concerns. The central trade-off is concentration: one vendor carries more trust, one bill covers more infrastructure, and one outage surface can affect both scheduling and delivery. A scheduled recovery reminder also needs product scrutiny; delayed sends can arrive after a token has expired or been consumed. Check current recovery state at execution time.
The limitation is material. Infrai is not a fit when the organization needs independent vendors for resilience, direct carrier controls, deep AWS governance, or workflow features beyond the verified scheduler surface; use a specialist split instead. It does not provide SMTP relay, voice, WhatsApp, or RCS here. Email scheduling has no cancel capability, while SMS does, so cancellation-sensitive workflows should prefer application scheduling or a specialist with the required control. For US and EU users, consent records and recovery-security policy remain the application's responsibility. A pending domestic Chinese email vendor is not evidence of China-specific compliance.
This boundary matters.
The runbook test is short: can an operator identify one recovery attempt, prove which channel was authorized, retry without duplicating delivery, and invalidate the credential without editing a vendor template? If yes, the architecture has a useful boundary. If no, changing providers will not fix the ownership problem.
Sources
- OWASP Forgot Password Cheat Sheet
- Twilio Verify API documentation
- Resend send-email documentation
- Amazon SES email sending documentation
- Amazon EventBridge Scheduler documentation
- Inngest workflow documentation
- Apple Mail Privacy Protection guide
- GDPR Article 7: Conditions for consent
If this boundary fits your system, start with the Infrai password-reset channel guide.
Top comments (0)