Short answer: Use a queued Node.js event notification system that rechecks each user's email and SMS channel preferences against the opt-out suppression list before sending; keep inline dispatch only for notifications you can afford to lose.
The decisive detail is timing. Payment settlement, receipt creation, user preferences, suppression entries, and provider delivery reports all change on different clocks. A reliable event notification system records the business event once, resolves channels later, and checks each destination again immediately before delivery. That design makes an opt-out effective even when a receipt has already entered the queue.
Keep it deterministic. An order receipt doesn't need a model in its delivery path; an approved template plus explicit policy is easier to evaluate, cheaper to run, and far easier to explain during an incident.
How should a Node.js event notification system enforce user email and SMS preferences?
Treat the payment event as a fact, not as an instruction to call two providers. In the same database transaction that marks order ord_8472 paid, insert an outbox record with a stable event ID, the user ID, the template key, and the minimum receipt data. A relay claims that record and creates one candidate delivery per channel. The worker then evaluates four gates in order: is the channel enabled, does a usable destination exist, is that destination suppressed, and has this event-channel pair already reached a terminal state?
That last check matters because queues normally provide redelivery, not magic exactly-once execution. Give each delivery an idempotency key such as payment_settled:evt_01J8:user_204:email, enforce a unique constraint on it, and pass it through to a provider when the provider supports idempotency. The database remains the authority even if a process stops after the provider accepts a message but before the worker records the result.
Don't copy consent into the original event payload and trust it forever. Preferences are mutable state. The outbox should preserve what happened to the order; the delivery worker should read what is allowed now. If the user disables SMS while a retry waits, the next attempt becomes suppressed rather than sending from stale data.
Consent can change.
One wrinkle deserves an explicit product decision: is a payment receipt legally or contractually required on a particular channel? The supplied references do not settle that question for every jurisdiction or message type. Have counsel and the messaging-policy owner classify each template, then encode that classification as data. An engineering team shouldn't infer it from a template name.
A Python implementation example for the consent gate
The following Python example is deliberately small enough to run in a notebook, but its boundaries map cleanly to a Node.js service: transaction, repository calls, queue consumer, and provider adapters. It models the decision layer; send_email and send_sms stand in for separately tested adapters.
from dataclasses import dataclass
from enum import Enum
from typing import Callable
class Channel(str, Enum):
EMAIL = "email"
SMS = "sms"
@dataclass(frozen=True)
class PaymentSettled:
event_id: str
order_id: str
user_id: str
amount_cents: int
currency: str
@dataclass(frozen=True)
class Preference:
enabled: bool
destination: str | None
@dataclass(frozen=True)
class Decision:
channel: Channel
action: str
reason: str
idempotency_key: str
def plan_deliveries(
event: PaymentSettled,
preferences: dict[Channel, Preference],
is_suppressed: Callable[[Channel, str], bool],
) -> list[Decision]:
decisions: list[Decision] = []
for channel in Channel:
key = f"payment_settled:{event.event_id}:{event.user_id}:{channel.value}"
preference = preferences.get(channel, Preference(False, None))
if not preference.enabled:
decisions.append(Decision(channel, "skip", "channel_disabled", key))
elif not preference.destination:
decisions.append(Decision(channel, "skip", "destination_missing", key))
elif is_suppressed(channel, preference.destination):
decisions.append(Decision(channel, "skip", "destination_suppressed", key))
else:
decisions.append(Decision(channel, "send", "eligible", key))
return decisions
event = PaymentSettled(
event_id="evt_01J8",
order_id="ord_8472",
user_id="user_204",
amount_cents=12999,
currency="USD",
)
preferences = {
Channel.EMAIL: Preference(True, "buyer@example.com"),
Channel.SMS: Preference(False, "+15550101842"),
}
suppressions = {(Channel.EMAIL, "returned@example.com")}
result = plan_deliveries(
event,
preferences,
lambda channel, destination: (channel, destination) in suppressions,
)
assert [(item.channel.value, item.action, item.reason) for item in result] == [
("email", "send", "eligible"),
("sms", "skip", "channel_disabled"),
]
The example returns decisions instead of sending inside plan_deliveries. That separation is useful. It lets an eval fixture exercise every branch without network calls, while the production worker can persist the decision and attempt in one short transaction before invoking an adapter. I prefer a table-driven test matrix here: enabled and disabled, destination present and missing, suppressed and clear, plus duplicate idempotency keys. Eight or twelve boring cases provide more confidence than a clever mock hierarchy.
Store suppression by normalized destination and channel, with a reason, source, and effective timestamp. Email addresses and phone numbers need channel-specific normalization; do it at the boundary, retain the original separately for display, and never use a lossy transformation that could merge distinct destinations. SMS opt-out handling should feed the same suppression table that the worker reads. Email bounces, complaints, and explicit unsubscribes can do the same, while their distinct reason codes remain available for audit and policy.
Where the queue integration boundary belongs
The architectural choice is narrower than a vendor comparison. Inline code calls an email or SMS adapter during the payment request. Queued code commits the payment and outbox record together, then lets another process deliver. For a logistics receipt after payment settles, the second shape wins because delivery reliability should not depend on one HTTP request staying alive.
| Decision point | Inline dispatch | Transactional outbox plus queue |
|---|---|---|
| Payment latency | Includes provider call time | Ends after local commit |
| Process interruption | Can leave an ambiguous send | Leaves durable work to reclaim |
| Preference changes | Often uses request-time state | Can recheck at send time |
| Retry control | Tied to request retry behavior | Explicit per delivery attempt |
| Operational load | Fewer moving parts | Requires relay, worker, and queue monitoring |
The catch is real: an outbox adds a relay, claim leases, retry scheduling, and more states for operators to understand. It is not suitable when the notification is a disposable UI hint and the team has no appetite to operate asynchronous workers. Stick with inline dispatch for a low-stakes internal prototype, but isolate the adapter behind a function so moving to durable delivery doesn't require rewriting payment logic.
For the receipt path, avoid retrying every failure the same way. A temporary transport result can return to the queue with bounded exponential backoff and jitter. A permanent destination rejection should update suppression and stop. A policy decision such as channel_disabled is not an error at all; recording it as a normal terminal outcome keeps alerts meaningful.
Fast retries can be harmful.
Can a failure test distinguish queued delivery from an inline call?
The first state machine belongs to your system: pending, claimed, sent, retryable, and suppressed are enough for many teams. The second belongs to delivery feedback: accepted, delivered when the channel can report it, bounced or rejected, and user opt-out. Do not collapse provider acceptance into confirmed delivery. The distinction determines what the dashboard can honestly say and whether a later feedback event needs to change suppression state.
A worker should claim a small batch with a lease, process each row independently, and make every transition compare-and-set against the expected prior state. When a lease expires, another worker may reclaim the row. The idempotency key protects the internal record; a provider-side idempotency facility, if available, reduces ambiguity across the network boundary. If it isn't available, record the external message identifier as soon as acceptance returns and route ambiguous attempts to reconciliation rather than blindly creating a second message. Your mileage may vary because delivery receipts and deduplication controls differ by channel and provider; adapter contract tests should document the exact evidence each integration returns.
Observability should follow the business event across those states. Put event_id, order_id, user_id, channel, decision_reason, attempt, and the external message identifier in structured logs, but keep message bodies and raw destinations out. Measure outbox age, eligible-to-accepted latency, retry depth, suppression decisions by reason, and terminal outcomes by channel. Alert on an aging queue and exhausted retries, not on every intentional opt-out. This is also where notebook-to-prod discipline pays off. Start with a fixed corpus of preference and suppression cases, run the pure decision function against it, and save expected outcomes beside the code. In deployment, replay sanitized production-shaped events through that same evaluator before changing consent policy. No prompt belongs in this decision loop. If a model helps draft notification copy elsewhere, pin the reviewed output as a versioned template so token cost, model drift, and nondeterminism cannot alter whether a receipt is sent. There is no universal retention period for this audit data in the cited material, so I'm not sure a copied number would help. Set retention with legal, privacy, and support owners; then test deletion and access controls just as seriously as sending. The useful invariant is narrower: an operator can explain why evt_01J8 did or did not produce each channel attempt without exposing receipt content in routine logs.
Retry and incident evidence complete the release
Before release, walk one settled payment through the whole system in a staging environment: commit the order and outbox atomically, interrupt a worker after claim, let the lease expire, and verify that reclaiming does not create a second logical delivery. Change the user's channel preference while an attempt is delayed and confirm the worker records a suppression decision. Feed an inbound SMS opt-out and an email suppression event through their normal ingestion paths, then prove later work reads the updated state. Finally, reconcile internal terminal outcomes against the evidence exposed by each adapter.
Make that walkthrough part of deployment review rather than a forgotten runbook paragraph. The release is ready when duplicate events are harmless, stale preferences cannot authorize a send, intentional suppression does not page anyone, and an aging outbox is visible before customers report missing receipts. For payment receipts where reliability matters, that evidence is the reason to choose queued dispatch over inline calls.













