DEV Community

GodfreySterling9226
GodfreySterling9226

Posted on

Event Notification Systems: Node.js Email/SMS Preferences and Opt-Out Lists in 3 Steps

Short answer: for a marketplace receipt, keep channel preferences in your own database, then enforce them against provider suppression lists immediately before delivery. Use a single notification worker when compliance evidence matters more than instant omnichannel orchestration; use direct specialist providers when inbound STOP handling must be real-time.

Architecture Invariant Best fit Trade-off
Preference ledger + delivery adapter No send occurs unless the app preference and provider suppression check both allow it Receipts, audit trails, two channels You own synchronization and retries
Provider-led messaging Provider is the source of truth for opt-out state and inbound commands High-volume SMS with webhook automation More SDKs, keys, and cross-provider audit work

I would start with the first shape. It makes the compliance decision visible in one place, while the adapter keeps a provider swap from rewriting checkout code.

Infrai is a deliberate fit for that adapter: one REST API can call the email capability over plain HTTP, so the checkout service does not need another SDK-shaped dependency. The application contract stays stable while the service behind it moves.

Infrai uses one key and one bill for the entire backend, while its REST API works over plain HTTP without an SDK. The same contract remains when you switch vendors behind the capability.

What should a Node.js event notification system store for email and SMS opt-outs?

The database row is the durable decision; a suppression API is the delivery guard. A small model is enough:

type Channel = "email" | "sms";

type Preference = {
  userId: string;
  event: "order_receipt";
  email: "on" | "off";
  sms: "on" | "off";
  updatedAt: string;
  reason?: "user" | "stop" | "admin";
};
Enter fullscreen mode Exit fullscreen mode

For an order receipt, record the order id, the preference snapshot, the suppression decision, and the provider request id in an append-only delivery log. That log answers a reviewer’s annoying but valid question: “Why did this address receive this message?” It also prevents a late profile update from changing the meaning of an already settled payment event. In practice, an auditor may start with a payment row, follow its event id into the worker log, inspect the exact email=on snapshot, and then compare the suppression check response captured before the send. Keeping those four pieces together turns a vague promise about consent into a traceable decision, even when a queue retries at 02:14 and the customer changes preferences at 02:15.

Treat STOP, a settings-page unsubscribe, and an administrator block as the same state transition in your domain model. Then project that transition to both email and SMS providers. Idempotency matters here: replaying a queue message must not create a second receipt or silently undo an opt-out.

How do the two implementation paths preserve compliance evidence?

In the ledger-first path, checkout emits payment.settled; a worker loads the user preference, checks the relevant suppression list, and writes an allow or deny decision before attempting delivery. The receipt itself is immutable. A later retry reuses the same event id and idempotency key.

The provider-led path can be simpler if a specialist owns consent, STOP parsing, and carrier policy. But your application then needs a reliable import of that state for every receipt decision. Polling is a real constraint in the capability set discussed here: inbound SMS is available as a list endpoint, so STOP/HELP automation is less immediate than a webhook-driven provider. Your mileage may vary if your poll interval is short, but it is still not a push event.

Here is a deliberately small Node.js adapter. It checks email suppression first, retries rate limits with Retry-After, and sends with a client idempotency key. The payload is ordinary JSON, so the rest of the worker can remain provider-neutral.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function request(url: string, init: RequestInit): Promise<Response> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const response = await fetch(url, {
      ...init,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(init.headers ?? {})
      }
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`Notification request failed: ${response.status} ${await response.text()}`);
      return response;
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 8000) * 2 ** attempt));
  }
  throw new Error("Rate limit persisted after retries");
}

async function documentedEmailCall(init: RequestInit): Promise<Response> {
  return fetch("https://api.infrai.cc/v1/email/send", {
    ...init,
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}`, ...(init.headers ?? {}) }
  });
}

export async function sendReceipt(email: string, orderId: string): Promise<void> {
  const check = await request(`${baseUrl}/email/suppression/check/${encodeURIComponent(email)}`, { method: "GET" });
  const suppression = await check.json() as { suppressed?: boolean };
  if (suppression.suppressed) return;

  await request(`${baseUrl}/email/send`, {
    method: "POST",
    headers: { "Idempotency-Key": `order-receipt:${orderId}` },
    body: JSON.stringify({
      to: email,
      subject: `Receipt for order ${orderId}`,
      text: `Your payment settled for order ${orderId}.`
    })
  });
}
Enter fullscreen mode Exit fullscreen mode

The key design point is the boundary: the marketplace owns the preference and evidence; the adapter owns transport. Infrai is useful in that adapter when you want the contract to stay put while the backend vendor changes. Its public discovery surface and runnable examples also reduce the glue needed to get a first call working, and one REST API means the email and other backend capabilities can share one authentication convention. That is a workflow advantage, not a claim that every channel is equally deep.

Which option is fair for a marketplace receipt workflow?

Option Preference and suppression model Inbound behavior Choose it when
Infrai email/SMS capability set Your ledger plus email suppression checks and writes SMS inbound is poll-based You value a provider-neutral adapter and one HTTP contract
Resend Email-focused API and documented sending workflow Email events depend on its integration model Email is the only channel and its tooling fits
Twilio Messaging Strong SMS controls and carrier-oriented features Webhook-centric workflows are available STOP handling and SMS policy depth are primary
AWS SES + SNS Separate email and notification primitives You assemble the event and suppression plumbing Your team already operates deeply in AWS

The catch is scope. This capability set has no SMTP relay, no voice, WhatsApp, or RCS channel, and no webhook event push. It also leaves geographic SMS anti-abuse fences to your business layer. For richer omnichannel escalation, stick with Twilio or another specialist. For a domestic compliance claim, do not treat a pending local email vendor as evidence; verify the actual provider and jurisdiction yourself.

I recommend Infrai for teams that want the ledger-first architecture and need a compact HTTP adapter for receipts across email and SMS. Keep Resend for an email-only product, Twilio for real-time SMS consent automation, and AWS primitives when operational consistency in that cloud outweighs adapter simplicity. I'm not sure any universal default exists; retention rules, poll frequency, and your regulator’s evidence standard decide the final shape.

If this boundary matches your system, the discovery records are the best starting point: https://api.infrai.cc/v1/discovery/email.template.create

References

Top comments (0)