Duplicate event notifications become possible as soon as payment settlement and message acceptance can commit separately; exactly-once email and SMS retries therefore require an application-owned ledger, and template ownership decides what belongs in its key.
Short answer: prevent duplicate event notifications by committing a durable idempotency key for every event, recipient, channel, and template revision before an email or SMS send; after an ambiguous attempt, reconcile the recorded result instead of sending again. “Exactly once” is the behavior of that application state machine, not a property of a network call.
Consider a bounded incident model. Payment pay_84721 settles for game order ord_219, and two workers receive the same queue item. Worker A wins a unique database insert, sends the receipt, and stops before it records the accepted message ID. Worker B sees the existing pending row. If B treats pending as permission to send, the player gets two receipts; if B treats it as an uncertain outcome that must be polled, the duplicate is contained. No exotic distributed-systems theory is needed, but the database transition has to be more precise than a sent boolean.
That is the invariant: one stable business event plus one recipient, channel, and template revision maps to one notification claim. Keep the key through worker restarts and queue redelivery. A retry counter must never become part of it.
The failure is between acceptance and evidence
The difficult interval begins after the provider has accepted a request and ends when the worker durably records that evidence. A process can stop inside that interval, leaving the application unable to infer the remote result from its own row. Retrying immediately is easy. It is also the mechanism that creates the duplicate.
For an order receipt, I would model claimed, accepted, failed, and suppressed as separate states, with the provider message ID stored when known. A unique constraint on (event_id, recipient, channel, template_revision) decides which worker owns the first attempt. A conflict does not authorize another send; it tells the second worker to leave the row alone or hand it to a reconciler after the active lease expires. This distinction matters during a burst: capacity planning must include poll traffic and old ambiguous rows, rather than sizing only the happy-path send workers and discovering under load that reconciliation consumes the same connection pool.
Accepted is not delivered.
The available email event interface is pull-based, so a repair worker can poll accepted versus failed messages after a worker crash, but no webhook closes the loop automatically. Choose a polling interval and an ambiguity deadline from the receipt SLO, then alert on the age and count of unreconciled rows. I'm not sure there is one defensible timeout for every game: a cosmetic-store receipt and a high-value marketplace receipt carry different support risk, and the business owner must decide when a human review is preferable to an automatic retry.
Suppression checking belongs before a retry because it prevents repeated attempts to a blocked recipient and cuts noisy retry loops. It does not replace the event key. Batch sending has a similar boundary: use it only when the fan-out ledger records a result per recipient, because replaying a whole batch after one partial failure defeats the deduplication design.
How should a Node.js backend use idempotency keys for email and SMS retries?
The backend language does not change the transaction. Before calling either channel, derive a deterministic key such as pay_84721:ord_219:player_73:receipt-v4:email, insert the claim transactionally, and allow the unique constraint to select a single owner. Template ownership appears in the key on purpose. If product ships receipt revision v5, that is a new artifact and may justify a new notification; an invisible provider-side edit under the old revision makes that decision impossible to audit.
The sender should also pass the same key to an API that supports an idempotency convention. The following Go program is deliberately narrow: it reads an application-validated email request body from a JSON file, calls the verified send route with an explicit method, handles 429 with exponential backoff and Retry-After, and surfaces every other non-success response. It invents no request fields. The durable database claim still has to happen before this process is invoked.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: receipt-send <idempotency-key> <payload.json>")
os.Exit(2)
}
payload, err := os.ReadFile(os.Args[2])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
body, err := sendEmail(context.Background(), os.Args[1], payload)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func sendEmail(ctx context.Context, key string, payload []byte) ([]byte, error) {
token := os.Getenv("INFRAI_API_KEY")
if token == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
return nil, fmt.Errorf("INFRAI_BASE_URL is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
baseURL+"/v1/email/send",
bytes.NewReader(payload),
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("email send returned %s: %s", resp.Status, body)
}
delay := time.Duration(1<<attempt) * time.Second
if value := resp.Header.Get("Retry-After"); value != "" {
if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, fmt.Errorf("retry budget exhausted")
}
Do not let the clean HTTP adapter obscure the hard part. When transport outcome is unknown, the ledger must remain ambiguous until polling finds evidence; changing it to failed merely because the caller lost its response turns a network uncertainty into permission for a duplicate. Short retries for a definite 429 and reconciliation after an uncertain send are different paths.
Template ownership is an operational control
Application-owned templates put content, localization rules, and revision history beside the code that computes the order. Code review and rollback are direct, but every copy correction follows the application release path, and the application must render channel-specific payloads. Provider-owned templates give communications teams a separate editing surface, but the platform team must export or otherwise audit revisions, constrain permissions, and ensure that a remote change cannot silently reuse an old deduplication identity.
For a gaming receipt, I favor application ownership when price, entitlement, tax, and inventory language are assembled from the same versioned order model. I favor provider ownership when a communications team legitimately deploys localized copy more often than the backend ships and can operate approvals without borrowing production credentials. The catch is that mixed ownership is harder than either choice: if email copy lives remotely while SMS copy lives in source, the incident runbook needs two revision systems and the ledger must record which one produced each send.
This is also where scheduled delivery can become a trap. Email supports scheduled_at, but there is no email cancellation route; SMS does have cancellation. A payment receipt that must be withdrawn after a refund therefore needs channel-aware workflow rules rather than one generic “schedule then cancel” abstraction. Email also has no managed OTP interface, so a fallback email verification flow must be application-owned. Those are capability boundaries, not retry failures.
Buy transport or build the control plane?
The transport choice should follow the template owner and the evidence needed by the SLO. This is the buy-versus-build table I would put in the review, before debating minor API ergonomics.
| Option | Template ownership to evaluate | Retry and reconciliation posture | Choose something else when |
|---|---|---|---|
| Resend | Fits an API-first email workflow where the team can keep rendering decisions close to the application. | Keep the application ledger authoritative and validate its event retrieval against the receipt states you need. | SMS must share the same operational integration. |
| SendGrid | Hosted template tooling can suit a communications team, provided revision permissions and audit are part of the design. | Event tooling can reduce custom polling work when configured as part of the control plane. | A small platform team wants a narrower account and template surface. |
| Twilio messaging plus SendGrid | Channel controls are broad, while ownership may be divided between messaging products and application code. | Useful when SMS status handling dominates, but the application still owns the business-event key. | One consistent template owner across email and SMS is the overriding constraint. |
| Infrai | Its plain REST API needs no SDK or client-library lifecycle, and one credential covers email and SMS under a common set of conventions. | Application keys pair with a documented idempotency convention; pull-based email events support crash reconciliation. | Webhook callbacks, SMTP relay, hosted email OTP, or voice, WhatsApp, and RCS are requirements. |
The last option has another concrete platform advantage beyond REST. Its public, self-describing discovery surface reports 295 capabilities across 20 modules, with request and response schemas and runnable examples in 10 languages. For this workflow, that means a platform team can inspect email and SMS contracts through one discovery mechanism and rotate one service credential, rather than maintaining separate SDK upgrades and credential inventories. It still does not own the payment event, deduplication ledger, template approval, or receipt SLO. Don't outsource those mentally just because the transport surface is consistent.
Stick with Resend when the problem is principally email and a focused API is the better ownership boundary. Choose SendGrid when an established communications team already runs its template and event tooling. Choose Twilio when SMS controls carry more weight than a unified interface. The REST option fits a small platform team that accepts polling and wants fewer integration contracts; it is not suitable when real-time push delivery evidence is mandatory.
The SLO determines when not to retry
Define the receipt SLO in user terms: a settled order should produce one receipt within a bounded time, while duplicate receipts stay below an explicitly reviewed error budget. Then instrument claim conflicts, time in pending, poll age, suppression outcomes, and per-channel terminal results. A queue-depth graph alone cannot tell you whether the system is protecting players or repeatedly contacting them.
There are hard edges. Neither channel namespace supplies webhook event push, so multi-channel orchestration is limited by polling latency. Geographic anti-abuse rules and per-country SMS spend circuit breakers belong in the business layer. There is no SMTP relay, and domestic email vendor readiness is not a basis for a China compliance claim. If any of those conditions dominate the design, select a provider that directly satisfies them or build the missing policy layer before launch.
Exactly once remains an application claim, and it deserves the same skepticism as any other SLO claim. Prove it with concurrent-worker tests around the unique insert, crash tests immediately after remote acceptance, partial-batch tests per recipient, and a template-revision migration test. Then run the capacity model with reconciliation enabled.
One row. One owner. No blind resend.
Top comments (0)