Short answer: a Node.js event notification system should resolve each marketplace user's channel preferences, email and SMS opt-out state, and suppression list immediately before sending a new-order alert, then store the result as an auditable delivery intent. A queue and a retry loop cannot repair a stale preference. The application needs one policy decision for each channel, a durable reason for every skip, and a transport worker that is forbidden to reinterpret that decision.
The awkward trade-off is intentional: checking policy later adds a database read and can reduce throughput, but checking only when the order event is created can send an alert after the seller has opted out. For email and SMS, a wrong send is often more damaging than a slightly slower send. That is especially true for a seller who receives a burst of order traffic during a promotion.
Start with the delivery contract, not the provider
An order event should describe what happened, not decide how a person must be reached. Keep the event small and durable: an event ID, seller ID, order ID, event type, and creation time. A separate notification planner can turn that event into channel candidates. The planner should never treat an email address or phone number as permission; those are destinations.
Model three different decisions in the application database:
- Channel preference: whether this seller accepts
order.createdthrough email or SMS. - Local opt-out: a scoped unsubscribe, STOP-derived block, or administrator block for the destination.
- Provider suppression: a destination-level block maintained by the email or messaging transport.
The first two are domain policy. The third is transport state. Combining them into a single boolean makes support investigations painful because false could mean a seller preference, a compliance block, an invalid address, or a provider-level suppression. Store the reason and scope alongside the state.
The delivery contract can be as plain as this:
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class DeliveryDecision:
allowed: bool
reason: str
preference_version: int
channel: str
destination: str
provider_suppressed: Optional[bool] = None
def decide_delivery(seller, event_type, channel, provider_suppressed):
preference = seller["preferences"].get(event_type, {}).get(channel)
version = seller["preference_version"]
destination = seller["destinations"].get(channel)
if preference is not True:
return DeliveryDecision(
False, "channel_preference_denied", version, channel, destination
)
if seller["local_blocks"].get(channel, False):
return DeliveryDecision(
False, "local_opt_out", version, channel, destination
)
if not destination:
return DeliveryDecision(
False, "missing_destination", version, channel, destination
)
if provider_suppressed is True:
return DeliveryDecision(
False, "provider_suppression", version, channel, destination, True
)
return DeliveryDecision(
True, "allowed", version, channel, destination, False
)
The function is deliberately conservative. Missing policy is not consent, and a provider check that cannot be mapped to a known response should stop the send rather than silently allow it. In production, normalize the destination before comparison, hash it in operational audit records where possible, and keep message content out of the policy table.
How should a Node.js event notification system handle user channel preferences, email, SMS, opt-out, and suppression lists?
Resolve policy twice when the workflow has a queue: once while planning and again immediately before transport. The first decision prevents obviously unwanted jobs from filling the queue. The second decision protects against a preference change while a job is waiting behind a rate limit, deployment, or temporary provider response.
For a new seller order, the sequence looks like this:
- Persist the order event with an idempotent event ID.
- Read the seller's current channel preferences and create candidate deliveries.
- Write a policy decision for each candidate, including its preference version.
- Enqueue only allowed candidates, carrying the event ID and a delivery ID.
- Re-read current policy in the worker before the send.
- Record the final decision and transport result with correlation IDs.
Imagine the event is created at 09:00 with preference version 12. The planner writes an email delivery and an SMS delivery, each carrying the same event ID but a different delivery ID, and both jobs wait behind a rate-limited worker. At 09:01, the seller disables SMS and unsubscribes from order email. At 09:02, an administrator blocks the phone number after a separate abuse review. At 09:03, the queue starts draining. A worker that trusts the original snapshot sends stale consent; a worker that checks only the local preference can still miss the newer phone block; a worker that resolves version 13 and current suppression at the delivery boundary skips both, records the distinct reasons, and makes no transport call. The audit row should show that the email was denied by a local unsubscribe and the SMS was denied by an opt-out or destination block, rather than collapsing both outcomes into not_sent. This is a small detail with a large operational payoff: the support team can explain what happened without reconstructing queue timing from scattered logs, and a replay tool cannot accidentally turn an old allowed decision into a new send.
Race conditions matter.
Use an outbox or equivalent durable handoff so an order commit cannot succeed while its notification event disappears. Give each delivery its own idempotency key. A retry of a send operation must reuse that key; otherwise a timeout after acceptance can become two seller alerts. A read operation can be retried according to its contract, but a write or send operation needs an explicit duplicate-prevention story.
Do not infer a successful send from an HTTP status alone. The adapter should validate the response shape, preserve the provider request ID, and classify the result as accepted, rejected, retryable, or permanently blocked. A 429 should respect Retry-After when present. Other failures should be visible to the worker and the alerting system, with bounded retries and a dead-letter path that an operator can inspect.
Opt-out is a consistency workflow
An unsubscribe or SMS STOP is not just a row update. It is a race between the user action, queued work, provider state, and the next retry. Write the local block first, increment the preference version, and make the local resolver enforce it immediately. Synchronize provider suppression afterward. If synchronization is delayed, the local veto still prevents a new send from your application.
Keep scope explicit. A seller might reject promotional SMS while still accepting a transactional order email, or an administrator might block every message to a phone number. Store channel, message category, source, timestamp, and scope. Never assume that one channel's opt-out applies to another unless the policy and applicable law say so.
Inbound SMS handling deserves its own design. A poll-driven inbound capability has different consent latency from a webhook-driven one, so persist a cursor, make consumption idempotent, and measure the time between a STOP arriving and the local block being effective. If near-instant inbound processing is mandatory, choose a messaging architecture with webhook delivery rather than hiding that requirement inside a queue worker.
Compliance still needs human review. I'm not sure how counsel will classify every transactional message in a particular jurisdiction, and an API reference cannot answer that. Confirm consent language, retention, quiet hours, sender registration, and the boundary between transactional and promotional traffic with the relevant legal and carrier specialists. CTIA guidance is a useful US SMS baseline, not a universal rulebook.
Test the failures that look like success
The happy path is the least interesting test. Build fixtures for a seller who changes preference after enqueue, a missing destination, a shared phone number, duplicate order events, concurrent opt-outs, a provider suppression that has not yet been synchronized locally, and a retry after rate limiting. For each fixture, assert both the send decision and the audit reason.
Contract-test each channel adapter against recorded response shapes owned by your team. The adapter boundary should answer a narrow question: did the transport accept this specific delivery, and what stable identifier did it return? It should not decide whether order.created is allowed. That keeps policy tests independent from vendor SDKs and makes a provider change a bounded adapter project.
Metrics should expose decisions, not recipient data. Track counts by event type, channel, decision reason, and transport outcome. Alert on an unexpected rise in suppression denials, a growing outbox age, repeated idempotency conflicts, and a gap between inbound opt-out time and local enforcement time. Log a correlation ID, event ID, delivery ID, preference version, and provider request ID. Avoid putting full email addresses, phone numbers, or message bodies into ordinary logs.
A useful staging exercise is to inject a preference change after planning but before the worker's final check. The expected result is boring: the worker declines the delivery, records the newer version, and leaves the transport untouched. Boring is the point.
Roll out the gate in small, observable steps
Start in observation mode. Run the resolver beside the current dispatcher, store its reasons, and prevent its shadow decision from sending. Compare disagreements by event type and channel; a security alert and a product announcement do not have the same consent semantics.
Next, enable one low-risk event on one channel. Keep a kill switch per event type, watch suppression denials and 429 responses, and confirm that a new preference version affects newly dispatched work. Then expand by channel and event category, retaining the audit trail so a rollback changes routing without erasing the explanation for earlier decisions.
The catch is that this design is not suitable when the product needs rich inbound conversations, voice escalation, or instant carrier-driven automation from one messaging surface. In that case, choose a transport that provides those capabilities and keep the same application-owned policy boundary. Stick with a simpler email-only integration when SMS is not part of the product; operating a second channel adds consent, routing, and incident-response work that a marketplace may not need.
Reliability is therefore a decision protocol, not a provider feature. The marketplace owns the meaning of an order alert, the seller's preferences, and the audit record. Email and SMS transports deliver only an already-authorized intent, with idempotency, bounded retries, and enough identifiers to explain every outcome.
Top comments (0)