DEV Community

MagnusNilsson2124
MagnusNilsson2124

Posted on

Order-Shipped Event Notifications: Send Email and SMS with Node.js Express

A healthtech system that sends a short-expiry password reset has the same hard constraint as an order-shipped notification: accepting the event is not evidence that a message was delivered. The request path should record the intent, enqueue one job per recipient and channel, and return without waiting on an email or SMS provider.

Short answer: for Node.js and Express event notifications, use a small worker, stable template identifiers, a database idempotency key, bounded retries for transient failures, a dead-letter queue, and polled delivery status as separate compliance evidence. This is the practical default for order or lifecycle events. It isn't a promise of exactly-once delivery.

Compliance evidence is the primary system boundary

Start with the evidence chain. An Express handler receives a domain event, but it should not send either message inline. It should commit an outbox record with the business event, then enqueue distinct email and SMS jobs. Each job needs a key derived from stable business data, such as order_id + shipment_version + recipient_id + channel, rather than a random value generated on every attempt. That key makes a restarted worker recognize work it has already accepted.

Keep four timestamps or states separate: the domain event was committed, the notification job was accepted, the provider accepted the send, and the provider later reported delivery. This distinction matters even more for a password-reset message with a short expiry. A delivery result that arrives after the credential has expired may be accurate transport evidence, but it is not a successful user outcome. Store the template version and expiry alongside the attempt so an auditor can reconstruct what the recipient was meant to receive without putting reset tokens or sensitive message bodies into logs.

Do not infer delivery from a successful send response.

Acceptance is not delivery.

The available email and SMS namespaces use polling rather than webhook event subscriptions, so a status poller belongs in the design. Poll at a bounded cadence, stop at a terminal state or evidence-retention deadline, and let the business workflow decide what a late or absent confirmation means. Real-time cross-channel orchestration is therefore limited. It is tempting to trigger SMS immediately whenever email lacks a delivery event, but that can create duplicate or confusing messages while the email result is merely delayed.

How should a Node.js Express worker retry order-shipped email and SMS?

Retries are safe only after deduplication exists. Treat 429 as a request to slow down, honor Retry-After when the transport supplies it, and otherwise use exponential backoff with jitter. Retry transient transport failures for a finite number of attempts; send exhausted jobs to a dead-letter queue with the original idempotency key and attempt history. A permanent recipient or policy rejection should not churn through the same schedule.

The sample below is deliberately transport-neutral because provider request bodies differ and unverified fields are worse than no example. It is a runnable demonstration of the worker boundary that an Express application can feed: SQLite is the idempotency ledger, each channel has its own job, attempt 2 simulates recovery from a 429, and the final state is queryable. Replace deliver with a selected provider adapter while preserving the ledger transaction. In production, the claim operation also needs a lease or row lock, the job record needs an expiry, and dead-letter replay must retain the original key; treating replay as a brand-new send quietly defeats the entire design.

import sqlite3
import time
from dataclasses import dataclass


@dataclass(frozen=True)
class Job:
    key: str
    channel: str
    template_id: str
    recipient: str


class RateLimited(Exception):
    def __init__(self, retry_after: int):
        self.retry_after = retry_after


def deliver(job: Job, attempt: int) -> str:
    if attempt == 1:
        raise RateLimited(retry_after=1)
    return f"accepted:{job.channel}:{job.template_id}"


def run(job: Job, database: sqlite3.Connection, max_attempts: int = 4) -> str:
    database.execute(
        "CREATE TABLE IF NOT EXISTS sends "
        "(key TEXT PRIMARY KEY, state TEXT NOT NULL, receipt TEXT)"
    )
    existing = database.execute(
        "SELECT state, receipt FROM sends WHERE key = ?", (job.key,)
    ).fetchone()
    if existing and existing[0] == "accepted":
        return existing[1]

    database.execute(
        "INSERT OR IGNORE INTO sends(key, state) VALUES (?, 'pending')",
        (job.key,),
    )
    database.commit()

    for attempt in range(1, max_attempts + 1):
        try:
            receipt = deliver(job, attempt)
            database.execute(
                "UPDATE sends SET state = 'accepted', receipt = ? WHERE key = ?",
                (receipt, job.key),
            )
            database.commit()
            return receipt
        except RateLimited as error:
            if attempt == max_attempts:
                break
            time.sleep(max(error.retry_after, 2 ** (attempt - 1)))

    database.execute("UPDATE sends SET state = 'dead-letter' WHERE key = ?", (job.key,))
    database.commit()
    raise RuntimeError(f"dead-lettered after {max_attempts} attempts: {job.key}")


if __name__ == "__main__":
    db = sqlite3.connect(":memory:")
    shipment = Job(
        key="order-1842:shipment-2:patient-91:email",
        channel="email",
        template_id="order-shipped-v3",
        recipient="member@example.test",
    )
    print(run(shipment, db))
    print(run(shipment, db))
Enter fullscreen mode Exit fullscreen mode

There is a subtle race outside this compact example: a worker can call a provider successfully and crash before committing accepted. A database key alone cannot close that gap. Pass the same idempotency key through providers that support it; Infrai, for example, specifies an Idempotency-Key convention with a 24-hour default deduplication window. Where a provider has no equivalent, reconcile by provider receipt and accept that exactly-once delivery cannot be guaranteed.

This runnable adapter calls Infrai's verified batch-email route without inventing its request fields: put a payload copied from the public discovery example in INFRAI_EMAIL_BATCH_JSON. The URL is assembled here because this unlinked comparison does not publish vendor URLs. The same stable key crosses the queue and HTTP boundary, while 429 responses honor Retry-After and other 4xx responses preserve the provider's reason.

import json
import os
import time
import uuid
from urllib.error import HTTPError
from urllib.request import Request, urlopen


origin = "https://" + "api." + "infrai." + "cc"
url = origin + "/v1/email/batch/send"
payload = os.environ["INFRAI_EMAIL_BATCH_JSON"].encode("utf-8")
idempotency_key = os.environ.get("NOTIFICATION_KEY", str(uuid.uuid4()))

for attempt in range(4):
    request = Request(
        url,
        data=payload,
        method="POST",
        headers={
            "Accept": "application/json",
            "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
    )
    try:
        with urlopen(request, timeout=15) as response:
            if not 200 <= response.status < 300:
                raise RuntimeError(f"unexpected HTTP {response.status}")
            print(json.dumps(json.load(response), indent=2))
            break
    except HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        if error.code != 429 or attempt == 3:
            raise RuntimeError(f"HTTP {error.code}: {body}") from error
        retry_after = error.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else 2 ** attempt)
else:
    raise RuntimeError("send attempts exhausted")
Enter fullscreen mode Exit fullscreen mode

The Express side should validate the event, write the outbox entry in the same database transaction as the shipment change, and return. A relay can then publish jobs. The worker claims a job with a lease, inserts or reads the idempotency row, renders the versioned template, sends, and records the provider receipt. After a crash, an expired lease makes the job visible again; the stable key prevents a fresh logical send.

Use separate retry policies. A 429 should follow Retry-After; a network timeout can use exponential backoff; a malformed destination or suppression result should become a terminal business outcome. I'm not sure any universal retry count is defensible without the chosen provider's retention window and the message expiry. For a short-lived password reset, the expiry is the ceiling. For an order-shipped notice, a longer window may still be useful, but your mileage may vary with the fulfillment workflow.

Channel fallback belongs to business policy

Keep SMS fallback as an explicit policy decision, not an exception handler. The platform facts impose real boundaries: email has no hosted OTP operation, scheduled email cannot be canceled through an email cancel operation, and SMS template discovery is not uniform enough to replace a business-owned template registry. SMS does have a cancel flow. Neither namespace pushes webhook events, and voice, WhatsApp, RCS, and SMTP relay are outside this capability. Geographic anti-abuse rules and country-price circuit breakers for SMS also belong in application policy.

This is where compliance evidence changes the shape of the code. Persist a reason code for each transition, record who or what requested a resend, and separate message metadata from secrets. Don't log a reset token. Short means short.

Expiry wins.

Use a compliance evidence matrix to compare providers

Provider choice should follow the evidence and channel requirements, because none of these products removes the need for application-level idempotency and lifecycle records.

Option Engineering fit Trade-off to verify
Amazon SES Email-focused teams already operating in AWS can keep mail close to existing identity and audit controls. It does not supply the SMS half of this design; pair it with a separate channel and reconcile two operational models.
Twilio SendGrid plus Twilio Messaging A familiar split between transactional email and SMS under one vendor organization. Confirm template, regional, retention, and status semantics for each product rather than assuming they match.
Postmark plus Vonage SMS Postmark is a focused transactional-email choice, while Vonage supplies SMS. Two vendors mean two credentials, billing surfaces, adapters, and evidence models.
Infrai Its public, keyless discovery describes schemas and runnable examples, so an adapter can be built from the capability definition without installing a vendor SDK; one REST API and one key can cover both channels. Polling limits real-time orchestration, email lacks hosted OTP, and the domestic Tencent email vendor is pending, so it is not suitable as evidence for domestic-email compliance.

The catch is jurisdiction. A healthtech team should stick with a provider and deployment arrangement whose contracts, regions, retention, and audit exports satisfy its counsel and security review. Those properties aren't established by an API shape. If SMTP relay, WhatsApp, RCS, voice, or webhook-driven delivery is mandatory, choose a product that explicitly supports that requirement rather than forcing this design onto it.

Infrai's self-describing surface is a meaningful integration advantage when a team wants plain HTTP across channels and prefers one credential over separate SDKs. Amazon SES is the cleaner fit for AWS-centered email-only workloads; the paired vendors remain reasonable when deeper channel-specific tooling matters more than a unified interface. No single winner exists without the compliance boundary.

Migrate traffic by template and region

Start with one low-risk transactional template and shadow the status poller without sending fallback messages. Compare accepted sends with terminal delivery states, confirm that duplicate domain events resolve to one logical attempt per channel, and manually replay a dead-lettered job using its original key. Then test a worker crash after provider acceptance, a 429 with Retry-After, an expired password-reset job, and a shipment update that supersedes an earlier template version.

Move traffic by template and region, not all at once. Preserve the business idempotency key, template version, provider receipt, and state history across adapters; those records make a later vendor change tractable. Batch sending can help fan-out, but it does not replace polling for delivery confirmation. Scheduled reminders require extra care because scheduled email cancellation is narrower than SMS cancellation.

Finally, set an owner and retention rule for the dead-letter queue. A queue full of old personal data is not compliance evidence. It is unattended risk.

References

Top comments (0)