Short answer: put one notification job behind the order event, render email and SMS from the same order snapshot, and make each provider attempt idempotent. The important Node.js decision is not which API has the nicest SDK. It is how much integration work the team will own around retries, delivery state, sender authentication, and consent.
For a gaming marketplace seller receiving a new-order alert, I would start with email as the durable detail channel and SMS as a short, urgent signal. The order service should publish an event; a notification worker should decide which channels are eligible; channel adapters should translate that decision into provider-neutral requests. Keep the order transaction away from network calls. A seller should not lose an order because an HTTP request to an email or SMS API was slow.
What must remain true after a provider fails?
The system needs a small set of invariants before it needs more SDK code:
- An accepted order is the source event. A notification is a consequence, never part of the order commit.
- One order and one channel have one stable notification key, such as
order-84721:seller-42:email. A retry may create another provider request, but it must not create another business decision. - Email and SMS have separate delivery state. An email rejection must not suppress an eligible SMS, and an SMS timeout must not roll back the order.
- The payload contains the minimum seller data required for the alert, including the order version used to render it.
- Every terminal state is observable: accepted, deferred, delivered, rejected, expired, or permanently failed.
That last point matters more than a green response from an API. A 202-style acceptance usually means a remote system accepted a request for processing; it does not prove that a seller saw the message. Treat submission and delivery as different facts, and consume callbacks or status polling according to the chosen integration.
That boundary is deliberate.
The event should carry an immutable snapshot for the first message:
order_event = { "event_id": "evt_6f2c", "event_type": "marketplace.order.created", "order_id": "ord_84721", "seller_id": "seller_42", "order_version": 3, "item_count": 2, "currency": "USD", "total_minor": 1899, "seller_email": "seller@example.test", "seller_phone_e164": "+15551234567", "created_at": "2026-10-31T09:15:00Z", } The phone number is in an international representation, and the two destinations still require separate policy checks. A phone number alone is not proof that SMS is permitted; an email address alone is not proof that a transactional message is permitted. Consent and suppression records should be explicit rather than inferred from a profile field.
Which integration shape minimizes work without hiding risk?
The decision axis here is integration effort. “Less code” is a poor measurement if it leaves the team to invent a delivery ledger, callback verification, or retry policy later. I compare the shapes by ownership boundaries:
| Integration shape | Work the team owns | Useful boundary | Main failure to plan for |
|---|---|---|---|
| Direct channel adapters in the worker | Request signing, retries, status mapping, callbacks, suppression | Full control over message and state model | Provider-specific behavior leaks into order workflows |
| A small internal notification gateway | One contract, routing, templates, audit records, provider adapters | Several applications need the same policy | The gateway becomes a hidden critical service |
| A hosted notification abstraction | Less transport and callback plumbing | Small team with limited messaging operations | Provider portability and unusual gaming rules may be limited |
For one marketplace application, direct adapters behind a narrow interface are usually the smallest defensible shape. For several products, an internal gateway can pay back its extra deployment only when the shared policy is real: common suppression, common audit requirements, or common template ownership. A hosted abstraction can reduce integration effort, but its contract becomes another dependency to test and monitor. None of these shapes removes the need to distinguish request acceptance from eventual delivery.
Do not put send_email() or send_sms() in the HTTP handler that creates an order. The handler should write the order and an outbox record in one database transaction. A publisher then emits the event, and a worker claims notification jobs with a lease. If the process dies after sending but before marking the job complete, the lease expires and the job is retried. That is why the stable notification key and duplicate-tolerant downstream behavior matter.
How do I build a notification system using email and SMS in Node.js?
The following Python sketch shows the boundary without tying the design to an SDK. In a Node.js service, the same pieces map to an outbox publisher, a queue consumer, and two HTTP adapters. The important order is policy, idempotency, bounded request, state transition, and telemetry.
from dataclasses import dataclass from typing import Protocol class Channel(Protocol): def submit(self, message: "Message", idempotency_key: str) -> str: """Return a remote submission identifier or raise a classified error.""" @dataclass(frozen=True) class Message: channel: str destination: str subject: str | None body: str def notify_new_order(event: dict, email: Channel, sms: Channel, store) -> None: channels = [] if store.transactional_email_allowed(event["seller_id"]): channels.append(("email", email, event["seller_email"])) if store.transactional_sms_allowed(event["seller_id"]): channels.append(("sms", sms, event["seller_phone_e164"])) for channel_name, adapter, destination in channels: key = f"{event['order_id']}:{event['seller_id']}:{channel_name}" if not store.claim_once(key, event["order_version"]): continue message = render_order_message(event, channel_name, destination) try: remote_id = adapter.submit(message, idempotency_key=key) except TemporaryChannelError as error: store.defer(key, reason=error.code, retry_after_seconds=60) except PermanentChannelError as error: store.fail(key, reason=error.code) else: store.mark_submitted(key, remote_id=remote_id) def render_order_message(event: dict, channel: str, destination: str) -> Message: text = f"New order {event['order_id']}: {event['item_count']} item(s)." if channel == "sms": return Message(channel, destination, None, text + " Open the seller console.") return Message(channel, destination, f"New order {event['order_id']}", text) The sketch deliberately leaves TemporaryChannelError and PermanentChannelError abstract. A timeout, connection reset, rate limit, and remote server failure are candidates for retry, subject to a bounded policy. An invalid destination, an explicit suppression, and a rejected sender identity should become actionable terminal states. Blindly retrying every exception is how a transient incident becomes duplicate seller alerts.
Use exponential backoff with jitter and a deadline, not an infinite loop. A practical initial policy can allow 5 attempts over 30 minutes, but those numbers are an operational choice: choose them from seller urgency, queue capacity, provider limits, and the lifetime of the order event. Store the next-attempt time and reason. That makes a queue-depth alert explainable instead of merely alarming.
The limitation is operational ownership. A direct adapter keeps the contract visible, but the team must maintain sender authentication, callback handling, test doubles, suppression rules, and on-call alerts. A gateway reduces repeated code only after several consumers share those rules; before that point, it adds another service, deployment, and failure boundary.
Email authentication belongs at the sender boundary. SPF publishes which hosts are authorized to send for a domain; it does not by itself describe the whole email authentication story. Keep the sending domain, return-path behavior, DKIM configuration, and DMARC policy in an owned runbook, and test them before production traffic. SMS needs its own sender registration, destination normalization, content restrictions, and opt-out handling. These are different delivery surfaces, even when the application calls them from one worker.
What should be rejected, and when is it valid?
I would reject a single “send notification” function that accepts arbitrary channel details and returns a Boolean. It compresses policy, transport, and delivery state into one call, so callers cannot tell whether a message was suppressed, accepted remotely, or lost after a timeout. It also encourages the order endpoint to own a network dependency.
That shape is valid for a prototype with no durable order state and no operational promise. It is a poor fit for a marketplace, where a seller can act on the alert and where duplicate messages have a visible cost. A direct call can also be valid for a one-off administrative tool that a human reruns deliberately. That exception should not define the event path.
The production boundary should expose facts rather than optimism: the order event was published, the channel job was claimed, the remote submission was accepted, and later delivery was reported or expired. Log the event ID, notification key, channel, attempt number, remote ID, latency, and classification. Do not log full message bodies or phone numbers by default. Alert on age of the oldest pending job, terminal failures by reason, callback verification failures, and the gap between submissions and final status.
Integration effort is therefore a measurable ownership decision. Count the adapters, state transitions, callback paths, test fixtures, runbooks, and on-call signals the team will maintain. The smallest safe Node.js implementation is the one that keeps those boundaries visible while allowing email and SMS to fail independently.
No magic. Just explicit state.
References
- RFC 7208, “Sender Policy Framework (SPF) for Authorizing Use of Domains in Email”: https://datatracker.ietf.org/doc/html/rfc7208
- RFC 5321, “Simple Mail Transfer Protocol”: https://datatracker.ietf.org/doc/html/rfc5321
- RFC 3463, “Enhanced Mail System Status Codes”: https://datatracker.ietf.org/doc/html/rfc3463
- Node.js documentation, “The Node.js Event Loop, Timers, and
process.nextTick()”: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick - OWASP, “Logging Cheat Sheet”: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html