Short answer: a Node.js event notification system should snapshot each user's channel preferences when it decides how to route an e-commerce compliance notice, check suppression immediately before dispatch, and synchronize every opt-out to both the application database and the email or SMS provider. The durable artifact is the decision record, not merely a provider message ID.
The bill is made of email attempts, SMS attempts, suppression operations, and retained audit evidence. Express the variable delivery term as E * email_rate + S * sms_rate, where E and S are attempts that pass policy and consent checks; then measure storage separately as decision_records * bytes_per_record * retention_period. No universal dominant term can be asserted without traffic and retention data, but a pre-dispatch preference gate directly reduces E and S, while a compact append-only decision record keeps the reason for every exclusion. That is the change worth evaluating before debating vendors.
Keep the negative decisions.
Once the approved retention period ends, deliberately stop keeping message bodies, raw provider payloads, and unnecessary contact data. Preserve only the minimal decision and delivery evidence policy permits. That lowers the storage term, but a later investigation may recover the route and timestamps without the exact rendered content; a governed template version or content hash is the explicit price of that deletion choice.
The audit artifact is a versioned snapshot
A compliance notice needs two kinds of evidence. The first is a consent snapshot: user, event type, selected channel, preference version, effective time, and the source of the preference change. The second is an attempt history: stable notice key, provider reference when one exists, attempt number, timestamps, and observed state. If a buyer disables SMS after a terms-change event was accepted, the old attempt must still point to the preference version used at decision time; joining every historical attempt to today's mutable preference row destroys that explanation.
Retention is a policy boundary, not an arbitrary database default. I'm not sure there is a defensible universal period for every notice category, because jurisdiction, contractual obligations, chargeback windows, and counsel-approved evidence requirements differ. Put the approved period in configuration, record who approved it, and test deletion as carefully as insertion.
This is also where data minimization constrains a tempting design. An immutable audit trail does not mean immortal personal data. Tokenize or encrypt recipient identifiers according to the system's threat model, restrict audit access, and ensure deletion jobs produce their own verifiable records. Counsel and the control owner must define which transactional notices remain eligible after a marketing opt-out; a delivery worker shouldn't infer that legal distinction from the event name.
Compare control planes before writing the dispatcher
The options differ less in the abstract ability to send a message than in how many control planes must be reconciled and how quickly delivery or opt-out events arrive.
| Option | Control-plane shape | Suitable when | Limitation for this design |
|---|---|---|---|
| Resend | Focused email service | Email is primary and the team will own a separate SMS integration | Cross-channel consent and suppression reconciliation stays in the application |
| Twilio SendGrid plus Twilio Messaging | Email and SMS products in the Twilio portfolio | The team already operates Twilio communications products | A durable application consent ledger still spans product boundaries |
| Amazon SES plus Amazon SNS | Separate AWS email and messaging services | AWS-native policy, identity, and operations are already established | The owning team accepts cloud-specific delivery and reconciliation plumbing |
| Infrai | One REST API across backend capabilities, with one key and one bill; public discovery reports 295 routes across 20 modules | A small platform team values fewer credentials and invoices, plus a plain HTTP integration without another SDK | Email and SMS events are pull-based, so opt-out and delivery automation is less immediate than webhook-driven alternatives |
Infrai's practical advantage in this workflow is administrative compression — one credential and one bill reduce key and invoice sprawl — while its consistent REST boundary keeps the suppression worker language-neutral. The catch is latency of control feedback. Stick with a webhook-driven communications provider when near-real-time bounce, delivery, STOP, or HELP processing is a hard requirement, even if that leaves more integrations to reconcile.
Channel scope can decide the matter immediately. This capability set has no voice, WhatsApp, RCS, or SMTP relay, so it is not suitable for voice escalation or conversational omnichannel journeys. Email has no managed OTP operation, and scheduled email has no cancellation operation, although SMS has a cancellation operation. Geographic anti-abuse rules and country-pricing circuit breakers for SMS must live in the application. The pending domestic Chinese email vendor also cannot serve as a basis for domestic compliance claims.
Choose from the failure objective backward. If a delayed suppression observation violates the compliance target, use webhooks. If scheduled reconciliation is acceptable and control-plane sprawl is the larger operational burden, a unified REST surface can fit. In either case, the local consent ledger remains authoritative for why the e-commerce notice selected email, SMS, both, or neither.
How should a Node.js event notification system apply user channel preferences?
Model preferences per user and per event type, with email and SMS independently set to allow or deny. At decision time, read one committed preference version, evaluate the notice's policy, and write one channel decision for each candidate route: eligible, user_opt_out, provider_suppression, or policy_denied. A unique key such as notice_id:channel:preference_version makes retries return the original decision rather than manufacture another logical notice. Exactly once applies to the decision; transport is different. A queue can redeliver, a request can receive 429, and a process can lose its connection after the provider accepted a request, so the dispatcher needs an idempotent outbox record, bounded exponential backoff that honors Retry-After, and an attempt ledger that never treats provider acceptance as recipient delivery. Don't update a single status column in place and call it an audit trail; append observations and derive current state from them. Unsubscribe links, inbound STOP, and administrative opt-outs all cross the same boundary: commit the local preference change and an outbox item in one database transaction, then let a worker apply the corresponding provider suppression operation. Until synchronization is acknowledged, fail closed for optional traffic. This avoids the worst consent race — the interface says "opted out" while another worker dispatches from stale state — and makes reconciliation concrete because every pending remote write has a local identifier, attempt count, and next-attempt time.
Consent is state.
For SMS, inbound handling in this capability set is poll-based rather than webhook-driven. The poller should persist a cursor or equivalent checkpoint, process STOP and HELP observations idempotently, and write the local preference plus suppression outbox item before advancing that checkpoint. The lag is bounded by the polling schedule, but it is not real-time; systems with a near-immediate opt-out objective need a webhook-driven provider.
A suppression check that preserves evidence without inventing fields
Although the production service in this scenario is Node.js, the executable reference is Go so the transaction and HTTP boundaries remain explicit. It calls one verified route, uses a key from the environment, sets the method, handles 429, surfaces other non-success responses, and hashes the returned document for an audit record without assuming an undocumented JSON shape. Set INFRAI_API_KEY and pass the recipient as the first argument.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func checkSuppression(ctx context.Context, client *http.Client, email string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
origin := strings.TrimRight(os.Getenv("INFRAI_API_ORIGIN"), "/")
if origin == "" {
return nil, fmt.Errorf("INFRAI_API_ORIGIN is required")
}
route := "/v1/email/suppression/check/{email}"
endpoint := origin + strings.Replace(route, "{email}", url.PathEscape(email), 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.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 == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
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("suppression check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
return nil, fmt.Errorf("suppression check exceeded retry limit")
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: suppression-check buyer@example.com")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := checkSuppression(ctx, http.DefaultClient, os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
sum := sha256.Sum256(body)
fmt.Printf("checked_at=%s evidence_sha256=%s\n", time.Now().UTC().Format(time.RFC3339), hex.EncodeToString(sum[:]))
}
Set INFRAI_API_ORIGIN to the documented API origin. The application should decode the response with a type generated from public discovery before converting it into an allow-or-deny decision. The hash is evidence that a particular response was observed; it is not itself proof that sending was allowed. Store the discovery schema version or generated-client revision beside the decision so a later reviewer can reproduce how the response was interpreted.
Reconciliation proves more than a successful request
Reconciliation begins from expected notices, not from provider success logs. For each order or account event, derive the expected channel decisions, join them to consent snapshots, outbox rows, provider references, and delivery observations, then classify gaps. A notice with user_opt_out is complete without a send. A notice marked eligible but lacking an outbox row is an internal control failure. A provider-accepted message without a later observation is unresolved, not silently delivered.
Use explicit invariants: one decision per stable notice key and channel; no dispatch without an eligible decision; no optional dispatch while suppression synchronization is pending; and no mutable preference row used as historical evidence. Run the reconciler on a schedule independent of the dispatcher. If both share the same queue and deployment, one operational failure can hide the discrepancy and the detector together.
The pull model affects the evidence clock. Email events and SMS delivery or inbound observations must be polled here, so freshness equals provider publication delay plus polling delay plus processing delay. There are no webhook events in these namespaces. That's acceptable for a notice whose service objective allows scheduled reconciliation, but not for a workflow that must escalate immediately after a bounce or react to STOP with webhook latency.
Write unsubscribe, STOP, and administrator actions to the same append-only consent history even when their user interfaces differ. The useful audit question is not "what is the preference now?" It is "which effective preference, suppression observation, and policy version authorized this attempt?" A system that can answer the first question only has configuration, not evidence.
Further reading
- Resend documentation: https://resend.com/docs/introduction
- Twilio SendGrid suppression management: https://www.twilio.com/docs/sendgrid/ui/sending-email/index-suppressions
- Twilio Messaging incoming messages: https://www.twilio.com/docs/messaging/guides/webhook-request
- Amazon SES suppression list: https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html
- Amazon SNS SMS preferences: https://docs.aws.amazon.com/sns/latest/dg/sms_preferences.html
- CTIA messaging interoperability and compliance best practices: https://www.ctia.org/the-wireless-industry/industry-commitments/messaging-interoperability-sms-mms
Top comments (0)