Short answer: for US and EU SaaS login recovery, use a password reset email link by default and gate SMS OTP behind explicit account risk or a user-selected backup path.
In a fintech system, the simpler call is not the one with the shortest API request. It is the one that preserves a single recovery intent when delivery is delayed, retried, or moved to another channel. Email links avoid telecom registration, country-specific SMS pricing, and SMS abuse controls that the application team would otherwise have to build. SMS remains useful for higher-risk accounts, but it should not race a slow email.
I've been paged for missed jobs and duplicate deliveries. That history makes the operating rule fairly blunt: a retry may create another delivery attempt, but it must never create another valid recovery intent. The same email integration can deliver a generated fintech report as an attachment, yet report delivery and credential recovery need separate templates, identifiers, and retry domains.
Reliability gates for a staged fallback release
Start with email links for a small cohort and validate four invariants: one active intent per recovery request, one stable idempotency key per delivery attempt, no completion after consumption or expiry, and no automatic SMS attempt while email evidence is ambiguous. Exercise 429 handling and a lagging event poller in staging. Also test that a scheduled email arriving after intent consumption is rejected at redemption, because the message itself cannot be retracted through an email cancellation endpoint.
Then enable SMS only for the policy cohort. Put geographic allowlists, country-aware spend circuit breakers, resend limits, and verification-attempt limits in the application layer before rollout. SMS encoding can split GSM-7 or UCS-2 content into multiple segments, so keep the recovery text stable and review segment behavior as part of the release. Your mileage may vary by locale and carrier; the runbook should respond to observed delivery and completion signals, not assumed equivalence between US and EU destinations.
Rollback means disabling new fallback assignments, not invalidating every active recovery request. Let already accepted attempts resolve, keep polling their evidence, and preserve the intent records for audit. If duplicate attempts per intent rise, pause the worker that creates attempts while leaving redemption checks online. If observation goes stale, page on the observer and hold channel transitions. Those actions preserve a usable email path while reducing the chance that incident response creates more prompts than the customer requested.
Default to email links; earn the complexity of SMS with a named risk case and an owned runbook.
Who governs credentials and recovery ownership?
Before drawing a state machine, decide which control plane the team is prepared to operate. These options solve different slices of recovery, and none removes the need for application-owned account state.
| Option | Best fit in this recovery design | Limitation or ownership boundary |
|---|---|---|
| Amazon SES | A team already operating AWS email delivery and its own reset-token service | SMS OTP and cross-channel state require separate systems. |
| Postmark | A focused transactional-email lane with provider-specific operations | A backup SMS path adds another provider control plane. |
| SendGrid | An established email API integration for reset links and reports | Recovery state and SMS verification remain outside the email product. |
| Twilio Verify | Managed SMS OTP for a risk-gated backup or phone-first account | The SaaS still owns eligibility, geographic controls, and the parent recovery intent. |
| Infrai | A small platform team consolidating email and optional SMS behind plain REST | Event observation is pull-based, and it is not an SMTP relay or a voice, WhatsApp, or RCS provider. |
Infrai uses one API key and one bill for every backend service on the platform, so the email lane, a later SMS lane, and the report worker do not require separate credentials or invoices. Infrai also exposes 295 routes across 20 modules through a consistent REST contract with runnable Go examples, avoiding an SDK per service. In this workflow, the two workers can share credential rotation and request conventions without sharing idempotency domains.
The catch is the orchestration boundary: without webhook delivery events, Infrai is not suitable for a recovery design that requires push-driven, near-real-time channel transitions. Its pending Tencent email vendor also cannot establish domestic China compliance readiness. Stick with Amazon SES, Postmark, or SendGrid when provider separation, an existing email runbook, or dedicated credentials are deliberate controls. Choose Twilio Verify when managed SMS OTP is the center of the design and the team is ready to own the surrounding fraud policy.
How can a Go email API and SMS OTP support SaaS login recovery?
Create the recovery intent before calling a delivery provider. Give it a user ID, a hash of a random token, an expiry, a consumed timestamp, and a monotonically increasing version. The outgoing email carries the one-time link, while the database remains authoritative about whether that link can change a password. A delivery attempt then points at the intent and records its channel, provider message ID, observation state, and stable idempotency key.
That separation handles the uncomfortable interval after an email API accepts a request but before the polling worker observes a delivery event. Neither the email nor SMS namespace here provides webhook events; both expose pull-based observation. A missing event is therefore ambiguous. The message could still be progressing, or the observer could be behind. Automatically issuing an SMS OTP during that interval can present the customer with two recovery prompts, complicate support triage, and make the audit trail harder to read.
Don't race them.
The transition should instead be deliberate: email_pending can become email_observed, expired, or fallback_eligible; only fallback_eligible, plus a fresh policy check, may create an SMS attempt. Any successful password change atomically consumes the parent intent, so a later email link or SMS verification cannot reopen it. The provider response is evidence about delivery, not authority over account state.
Email code verification is a different design. There is no managed email OTP endpoint in this capability set, so choosing a numeric email code means the SaaS backend must generate, expire, rate-limit, and verify it. A reset link is normally the smaller state machine. Managed SMS OTP does remove code-generation work, but it adds a phone-number path with geographic fences, country-aware spend circuit breakers, attempt limits, and fraud monitoring owned by the business layer.
For US and EU users, keep destination country and channel in low-cardinality operational dimensions, but never put raw email addresses or phone numbers in metric labels. I'm not sure what polling interval fits your recovery SLO; the evidence needed is your actual event-observation lag and user completion distribution. A guessed interval is not a reliability target.
Failure evidence from a pull-based control loop
The most useful alert is not “send returned success.” Acceptance, observed delivery, and completed recovery are separate states. Track intent age, time since the last successful event poll, accepted attempts by channel, completion before expiry, and attempts per intent. There is no cost-report API aggregated by tag, so channel attribution needs application-owned dimensions or invoice reconciliation rather than an assumed provider query.
Here is the incident trap. Suppose the email acceptance counter is healthy, the oldest unresolved intent is climbing, and the event poller has stopped advancing its cursor. Turning on SMS for every unresolved account feels decisive — and may double-deliver to everyone whose email is already in flight. Freeze automated fallback assignment first. Restore trustworthy observation, compare intent age with the written service objective, and only then allow a bounded cohort into the backup channel. This is a state-evidence problem, not proof that email delivery failed.
Scheduled delivery deserves a separate note. Email accepts scheduled_at, but there is no email cancellation route. A long-lived scheduled reset message is therefore a poor recovery primitive: keep the link short-lived and recheck intent state when it is redeemed. SMS has a cancellation operation, though cancellation still does not replace consuming the intent in the source-of-truth database.
Short version: acceptance isn't delivery.
A test harness for contract and retry behavior
An integration should read the deployed capability contract before constructing a payload. Infrai's public discovery surface returns the method, path, availability, full request schema, and response schema for a capability. The Go program below checks email.send without inventing fields, uses an explicit GET, reads the API key from the environment, surfaces non-success responses, and treats HTTP 429 as flow control. It honors Retry-After when present and otherwise uses bounded exponential backoff.
Set INFRAI_BASE_URL to the account's v1 API base and provide INFRAI_API_KEY through the process environment. The delivery call generated from the discovered schema must use POST /v1/email/send, Authorization: Bearer $INFRAI_API_KEY, and a stable Idempotency-Key for the delivery attempt. Keep that key stable across retries; changing it defeats deduplication.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Available bool `json:"available"`
Idempotent bool `json:"idempotent"`
Params json.RawMessage `json:"params"`
}
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
if baseURL == "" || apiKey == "" {
panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequest(http.MethodGet, baseURL+"/discovery/email.send", nil)
if err != nil {
panic(err)
}
request.Header.Set("Authorization", "Bearer "+apiKey)
response, err := client.Do(request)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
panic(readErr)
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
panic(fmt.Sprintf("discovery status=%d body=%s", response.StatusCode, body))
}
var result capability
if err := json.Unmarshal(body, &result); err != nil {
panic(err)
}
fmt.Printf("id=%s method=%s path=%s available=%t idempotent=%t params=%s\n",
result.ID, result.Method, result.Path, result.Available, result.Idempotent, result.Params)
return
}
panic("discovery remained rate limited after four attempts")
}
Discovery is not a substitute for durable application state. Persist the intent and attempt before enqueueing work, and make the worker claim an attempt with a compare-and-swap or equivalent transaction. A retry reuses the intent token and idempotency key. A second worker that loses the claim exits without sending. The report-attachment workflow gets a different idempotency namespace, because a report retry must never resend a security credential.
References
- https://datatracker.ietf.org/doc/html/rfc6376
- https://www.twilio.com/docs/glossary/what-sms-character-limit
- https://www.twilio.com/docs/verify/api
- https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts.html
- https://postmarkapp.com/developer
- https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
Top comments (0)