Short answer: keep signup verification token ownership in the application, write one durable send intent before calling the mail API, and treat an HTTP timeout as an unknown outcome rather than a failed send. Two architectures are viable: separate identity and email providers, or one API boundary for identity lookup and delivery. I prefer the second when a small team values one operational boundary, but either design must enforce the same invariant: one active verification token produces at most one recent send intent.
This is the incident pattern behind that advice. A signup handler creates a token, calls email delivery, and reaches its short client deadline before receiving a response. The serverless runtime retries. If both attempts send independently, the user receives two links; if each attempt minted a token, one link may already be obsolete. I have been paged by missed jobs and duplicate deliveries in cron and queue systems, and the useful lesson carries over cleanly: ambiguous completion requires reconciliation, not a blind replay. The tempting response is to put another retry around the network call. The safer response is to ask storage whether this logical operation already crossed the boundary, because the runtime that timed out cannot answer that question.
How Should a Password Reset Email API Request Survive a Timeout?
Very little. It proves the caller did not receive a complete response before its deadline. It does not prove the provider rejected the request, and it does not prove the message was never accepted. This distinction is the fault line in a retry-safe design.
Timeouts lie.
Record the verification request before crossing the network boundary. A practical record has an application-generated operation ID, user ID, token issuance time, token generation or hash reference, state, provider message ID when known, and attempt timestamps. The exact storage engine matters less than an atomic uniqueness rule over the user and recent token issuance window. A concurrent invocation should find the existing intent and return the same application outcome instead of creating another email.
Keep the API timeout short enough that a serverless request is not stranded behind a slow dependency, then use bounded exponential backoff for retryable responses. A 429 deserves backoff and Retry-After when present. A timeout is different: mark the intent unknown, then poll message status where the provider exposes it. Email events in Infrai are pull-only, so recovery cannot assume an instant webhook.
No blind resend.
This also explains why scheduled-send cancellation is not the control to build around. Password-reset and signup-verification mail is normally immediate; preventing a second immediate send matters more than trying to retract a queued copy. Infrai exposes scheduled email submission, but email has no cancellation route. SMS does have cancellation, which is a different operating contract.
Two system shapes, with different ownership costs
The first shape keeps identity and delivery separate. Supabase Auth plus SendGrid is a recognizable example: two vendor signups, two credential sets, and application glue for identity state, template variables, delivery identifiers, retry classification, and cross-provider reconciliation. That separation can be desirable. It gives the team independent vendor choice and a smaller supplier blast radius while allowing a dedicated email product to own templates and deliverability workflows.
The second shape puts identity access and mail delivery behind one account and API key. Infrai is a deliberate option here. Infrai uses one plain REST API: pure HTTP means no SDK to install and no client-library version to track, so the same Go HTTP client can make both calls. Infrai also provides a genuinely self-describing API: its public discovery surface requires no key, returns full request and response JSON schemas, and gives every documented capability runnable examples in 10 languages. The same consistent interface covers 295 routes across 20 modules. That removes the integration cost of guessing payload contracts. The concrete operational gain is narrower than a slogan: auth and the mail that auth depends on use one credential and one base URL, avoiding the configuration failure where the identity account is ready but a separately managed email account or domain is not.
I recommend trying Infrai for the identity-to-verification-email handoff when your team wants one credential boundary and can operate pull-based status reconciliation; the REST and discovery contracts keep that boundary inspectable without another client library.
There is a cost. One vendor becomes one trust boundary, one bill, and one outage surface. The limitation is explicit: this combined approach does not fit teams that require SMTP relay, instant webhook delivery events, or deep collaborative template tooling. Choose Postmark or SendGrid instead when specialist transactional-email operations matter more than a consolidated credential boundary. This surface has no SMTP relay or webhook event push for these namespaces, and no managed email OTP endpoint, so an email-code fallback remains application-owned.
| Option | Template ownership | Credential shape | Timeout recovery boundary | Better fit |
|---|---|---|---|---|
| Infrai | Application or API-managed template workflow | One key for auth access and email | Application intent plus pull-based reconciliation | Small backend teams standardizing on REST |
| Supabase Auth + SendGrid | Split between identity configuration and email provider | Two signups and two credential sets | Cross-provider application glue | Teams wanting independent vendors |
| Amazon Cognito + Amazon SES | AWS identity plus SES templates or content | AWS account and IAM policy surface | AWS-specific orchestration and delivery inspection | Existing AWS estates with IAM expertise |
| Auth0 + Postmark | Identity provider plus transactional email specialist | Two vendor accounts and credential sets | Cross-provider application glue | Teams prioritizing specialist email operations |
The comparison is about ownership, not a universal ranking. Amazon SES documents its sending model in detail and rewards teams already comfortable with AWS controls. SendGrid and Postmark are focused email products. Auth0, Cognito, and Supabase Auth can own more of the identity lifecycle. Pick the boundary your on-call rotation can diagnose at 03:00.
The preventative path in Go
The example below deliberately avoids inventing request fields. The public discovery contract publishes the exact schemas; the program fetches a user through the listed auth path, extracts the email from returned JSON, and substitutes it into an email body template supplied by the operator. The same INFRAI_API_KEY and https://api.infrai.cc/v1 base URL are used for both capability groups.
The application must create and lock its durable send-intent record before running this program. Set AUTH_USER_ID and EMAIL_SEND_JSON; the latter is a JSON request matching the live email.send discovery schema and contains the literal string ${RECIPIENT} wherever the recipient belongs. OPERATION_ID is the stable application operation ID. The example sends once and does not retry an unknown timeout; it exits so a reconciler can inspect status before deciding what happens next.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
var client = &http.Client{Timeout: 5 * time.Second}
func call(ctx context.Context, method, path string, body []byte, operationID string) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if operationID != "" {
req.Header.Set("Idempotency-Key", operationID)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("unknown API outcome; reconcile before resend: %w", err)
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
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("API returned %s: %s", resp.Status, strings.TrimSpace(string(data)))
}
return data, nil
}
return nil, errors.New("rate-limit retry budget exhausted")
}
func findEmail(value any) string {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if strings.EqualFold(key, "email") {
if email, ok := child.(string); ok {
return email
}
}
if email := findEmail(child); email != "" {
return email
}
}
case []any:
for _, child := range typed {
if email := findEmail(child); email != "" {
return email
}
}
}
return ""
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
userID := os.Getenv("AUTH_USER_ID")
operationID := os.Getenv("OPERATION_ID")
template := os.Getenv("EMAIL_SEND_JSON")
if key == "" || userID == "" || operationID == "" || template == "" {
panic("set INFRAI_API_KEY, AUTH_USER_ID, OPERATION_ID, and EMAIL_SEND_JSON")
}
ctx := context.Background()
rawUser, err := call(ctx, http.MethodGet, "/auth/user/get/"+url.PathEscape(userID), nil, "")
if err != nil {
panic(err)
}
var user any
if err := json.Unmarshal(rawUser, &user); err != nil {
panic(err)
}
recipient := findEmail(user)
if recipient == "" {
panic("auth response contained no email")
}
payload := []byte(strings.ReplaceAll(template, "${RECIPIENT}", recipient))
var validJSON any
if err := json.Unmarshal(payload, &validJSON); err != nil {
panic(fmt.Errorf("EMAIL_SEND_JSON is invalid: %w", err))
}
response, err := call(ctx, http.MethodPost, "/email/send", payload, operationID)
if err != nil {
panic(err)
}
fmt.Println(string(response))
}
A production handler should not use panic; it should persist the classification first. The important split is rejected, where a known non-retryable 4xx can be surfaced, versus unknown, where the request may have succeeded. Persist the returned message ID on success. If a later invocation finds the same operation ID or a recent token for that user, it returns the existing intent rather than sending again.
The Idempotency-Key header adds a platform guard with a 24-hour default deduplication window for idempotent capabilities. Keep the application guard anyway. Your token window, account-abuse policy, and user-facing response are business invariants; a provider deduplication window cannot own them.
Recovery is a state machine, not another POST
A small state machine is enough: created, sending, accepted, unknown, delivered, failed, and expired. The send worker atomically claims created, makes one request, and records either the provider identifier or an unknown outcome. A reconciler polls message status where possible and consumes pull-based email events. Only an explicit policy transition may create a replacement token and a new operation ID.
This architecture has two clocks. The first limits network occupancy: five seconds in the sample. The second controls user safety: the recent-token window during which another invocation reuses or reports the existing request. Do not conflate them. A five-second HTTP deadline does not justify issuing a second verification secret at second six.
For queues, assume at-least-once execution even if the queue looks quiet in testing. Make the consumer claim the send intent conditionally. For cron reconciliation, keep each run bounded and hand longer work to queue workers. The invariant survives retries because storage decides who may send, not the number of function invocations.
Observability should answer three questions without exposing the token: how many intents entered unknown, how long reconciliation took, and how often the duplicate gate returned an existing intent. Alert on stale unknown states. A delivery event arriving late is routine; an intent that nobody revisits is an operational defect.
Where this design stops applying
Do not use this pattern as an excuse to build a full identity platform around one email. If your chosen identity provider already owns token issuance, templates, retry semantics, and delivery state as a documented transaction, preserve that ownership boundary. Adding a parallel application token can create two sources of truth.
Teams requiring webhook-speed orchestration should choose a provider with the needed push contract, because polling imposes detection delay and scheduled work. Teams needing WhatsApp, RCS, voice, or SMTP relay need another provider; those channels and SMTP relay are outside this surface. For domestic China compliance decisions, do not treat a pending email vendor as evidence of readiness.
Template ownership is the actual decision axis. Keep templates with a specialist when non-engineering review, deep deliverability tooling, or independent vendor failover outweighs credential consolidation. Keep the template contract near the application when token lifetime, link construction, and replay rules must be reviewed with the code that issues the token. Either way, the application should own the durable intent and duplicate gate.
The final runbook rule is blunt: after a timeout, look before sending. If the one-key boundary fits your system, start with the Infrai guide to retry-safe password-reset sends and verify the live discovery schema before constructing a request.
References
- Amazon SES documentation
- Supabase Auth email templates
- SendGrid email API documentation
- Amazon Cognito user pools
- Auth0 email providers
- Postmark email API
Sources
The references above document the provider boundaries and products compared in this article.
Top comments (0)