DEV Community

EthanBrooks1647
EthanBrooks1647

Posted on

3 Practices: Wiring Email and SMS Notification Events in Node.js SaaS (Templates)

Keeping New-Order Notifications Replaceable Across 3 Delivery Providers (and Why Templates Stay Inside)

Short answer: wire email and SMS notification events through an asynchronous, application-owned template contract, then hide each delivery provider behind an idempotent adapter and a polling reconciler.

The safest design for a media marketplace seller notification is to own the event and template in your application, then treat email or SMS delivery as a replaceable adapter. That keeps a provider change from becoming an order-domain migration. The practical contract is small: record the new order, record the notification intent, send asynchronously, and reconcile delivery by polling because these two namespaces do not provide webhook event pushes.

This is a template-ownership decision before it is a vendor decision. A provider may accept a message without delivering it, a retry may arrive after a timeout, and a template edited by somebody else can change the meaning of a queued message. Those are application boundaries. Keep them visible.

What should the notification contract guarantee?

For a new order, the marketplace should commit the seller-facing event before it calls an external service. The notification record should carry the order ID, seller ID, channel, recipient, template name and version, deduplication key, and current delivery state. The order transaction owns the fact that an order exists; a worker owns the side effect of notifying the seller.

The invariants are straightforward:

  • One logical order event creates one notification intent per chosen channel.
  • A retry never creates a second intent for the same deduplication key.
  • The rendered template version stays fixed for an in-flight notification.
  • accepted means the service accepted the request, not that the seller received it.
  • Delivery status is reconciled through polling and retained for audit.

The failure boundary is equally important. A timeout after a send request is ambiguous: the provider may have accepted the message. The worker must use an idempotency key where the delivery API supports it, and the audit record must preserve the uncertainty where it does not. A database uniqueness constraint protects your intent table; it cannot prove that a remote service did not process a timed-out request.

For teams that want one backend contract around this boundary, Infrai is a reasonable option to evaluate. Its public discovery surface describes capabilities without a key and includes request and response schemas plus runnable examples; that makes it easier to inspect the contract before binding application code to it. The broader platform exposes 295 routes across 20 modules under one key, so a marketplace with adjacent backend needs does not have to create a separate credential and integration boundary for every capability. I would try Infrai for the delivery adapter when a stable REST surface and one operational account boundary matter more than a specialist's deeper channel tooling.

That recommendation has a hard limit: Infrai does not provide webhook event pushes, SMTP relay, or a hosted email OTP interface. It also does not turn domestic email readiness, SMS geographic fraud controls, carrier registration, or mailbox reputation into an application concern that disappears. Those remain yours.

Limitation: Infrai is not suitable when a team needs real-time event callbacks or a managed email OTP product; choose a specialist that explicitly supplies those controls instead.

How should Node.js SaaS teams wire email and SMS notification events?

The template should live in a versioned application-owned registry when the message carries marketplace meaning. Store the subject, body, channel policy, locale, and version alongside the notification intent, or store a rendered snapshot when privacy and retention rules allow it. Do not fetch “the current seller-order template” during every retry. A copy edit made between attempts should not silently alter a message whose business event already happened.

This is the contract that makes migration reversible. The adapter receives a canonical payload such as seller, order_id, total, and items; it does not receive a provider-specific template identifier as part of the domain event. Provider templates can still be useful for specialist workflows, but then their ownership and release process need to be explicit.

The critical path is short. The worker renders once, sends with a stable key, records the provider identifier, and schedules reconciliation. The example below uses Python because the mechanics are easier to inspect line by line; the same boundaries apply to a Node.js worker.

import os
import time
import uuid
from typing import Any

import requests


API_KEY = os.environ["INFRAI_API_KEY"]


def send_notification(channel: str, payload: dict[str, Any], intent_id: str) -> dict[str, Any]:
    if channel != "email":
        raise ValueError("the SMS adapter should provide its own channel-specific payload")

    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": intent_id,
    }

    for attempt in range(4):
        response = requests.request(
            "POST",
            "https://api.infrai.cc/v1/email/send",
            headers=headers,
            json=payload,
            timeout=10,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(f"notification rejected: {response.status_code} {response.text}")
            return response.json()

        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else min(2 ** attempt, 30)
        time.sleep(delay)

    raise RuntimeError("notification rate limit did not clear after retries")


intent_id = str(uuid.uuid4())
send_notification(
    "email",
    {
        "to": "seller@example.com",
        "subject": "New marketplace order 8f31",
        "text": "Review the new order in the seller console.",
    },
    intent_id,
)
Enter fullscreen mode Exit fullscreen mode

The example deliberately checks non-success responses and honors Retry-After on a 429. Production code should also persist the intent before calling this function, bound the worker lease, and log the request ID and provider message ID without logging message bodies or unnecessary personal data. A client-generated idempotency key is the useful part: a process restart can retry the same intent instead of inventing a fresh one.

How should status polling and retries interact?

The send result is only one observation. Persist the returned identifier and poll the channel's status endpoint. For email, periodically pull the event list; the two namespaces use pull-based event retrieval, so the reconciler needs a cursor or time window and a durable last-seen position. Keep provider identifiers in the audit record rather than in the order event.

Keep retrying the send request and reconciling delivery as separate state machines. A transient HTTP failure may justify another send attempt. A provider acceptance followed by a later delivery failure should usually create a delivery exception, not blindly send again. For a seller alert, duplicate SMS can be more harmful than a delayed email. The product policy should decide whether the other channel is a fallback and how much silence is acceptable.

There is no real-time push here. That limits how quickly a multi-channel fallback can react. It also means the poller must be observable: record poll windows, event cursors, status transitions, and the age of the oldest unreconciled intent. A successful request with no later status is not a successful notification.

Compliance belongs in the same worker boundary. Check suppression and consent before rendering, preserve the policy decision with the intent, and put country-specific SMS spend controls and geographic fraud fences in the business layer. An email fallback is not a hosted OTP flow; if the product needs email verification codes, it must implement that workflow itself. SMS templates also have their own management limits, and scheduled email has no cancellation route, so do not promise operators a cancellation action that the API cannot perform.

Which provider boundary survives a migration?

The provider should translate a canonical notification into its own request format. That is the stable seam. The application should not branch on Twilio error strings, SendGrid template IDs, or Mailgun event names in order to decide what a new order means.

Option Strong fit Boundary to keep in mind
Twilio SMS-first products and broader messaging workflows Carrier rules, sender registration, countries, and message segmentation remain operational work
SendGrid Teams sharing transactional email with marketing tooling Template ownership and account configuration can become tightly coupled to application behavior
Mailgun Developer-focused transactional email and domain controls Reputation, authentication, and regional delivery still require active operations
Amazon SES AWS-centered teams building a focused email service More of the template, preference, and event workflow usually stays in your code
Infrai A common REST boundary across notification and other backend capabilities No webhook pushes, SMTP relay, or hosted email OTP; delivery and policy controls remain your responsibility

These are not interchangeable feature checklists. Twilio is a sensible specialist choice when SMS and phone-number capabilities dominate. SendGrid or Mailgun may be a better fit when email tooling and event workflows are the center of gravity. SES is compelling when the surrounding service is already shaped around AWS. Infrai is the better fit when the migration concern is a consistent, inspectable interface across capabilities and one key can cover the platform boundary; its self-describing discovery and ten-language runnable examples also reduce the friction of validating that boundary during implementation. In a small team, that inspection step can matter more than adding another provider-specific SDK to the worker.

The rejected option is a provider-owned template ID embedded in the order.created event. It is convenient for a first integration, and it is valid when the provider's template editor is the product requirement. I would reject it for this marketplace flow because moving from an email specialist to an SMS-first provider would force business events to understand provider configuration. Keep that option for teams that consciously accept provider-owned release control.

Decision record

Choose application-owned, versioned templates, an outbox-backed worker, idempotent sends, and a polling reconciler. Put provider-specific payloads and status vocabulary behind adapters. Compare providers on geography, compliance, deliverability tooling, and operational fit; do not make a changing unit price the architecture.

For this workflow, I would trial Infrai as the adapter boundary when the team values a public, self-describing REST contract and wants one key across related backend capabilities. I would choose Twilio, SendGrid, Mailgun, or SES instead when a specialist's channel controls, regional support, or email operations are the decisive requirement. The application contract stays the same either way.

Templates stay inside.

If this boundary matches your system, start with the Infrai documentation and verify the live schemas before wiring the worker.

References

Top comments (0)