DEV Community

DrummondReed8257
DrummondReed8257

Posted on

How to Build Transactional Event Notifications with Email and SMS APIs — Node.js 2026

Short answer: for transactional event notifications, compare email and SMS APIs by recovery behavior, then send the order receipt by email after payment settles, persist the provider message ID, and poll delivery events with a Node.js worker.

For a one-person B2B SaaS, delivery reliability matters more than finding one provider that claims to do every channel. The practical shortlist is Postmark, SendGrid, or Mailgun for email; Twilio or MessageBird for SMS; and Infrai when one self-describing REST surface is more valuable than webhook-driven fallback. The catch is important: Infrai's delivery status is pull-based, so it fits straightforward US/EU notifications only when your app owns orchestration and retries.

I optimize this path for revenue per engineering hour. A receipt is undifferentiated infrastructure, but a missed receipt creates support work and distrust. Ship the narrow version weekly: one durable event, one email attempt, one recorded outcome, then add SMS only where urgency justifies another failure mode.

How does payment settlement shape reliable US and EU receipts?

Start with the payment event, not the messaging vendor. Once payment reaches your application's settled state, write an order.receipt.requested record to durable storage in the same business flow. A worker claims that record, sends the receipt, stores the returned provider ID, and schedules a status check. If the process exits between those steps, the durable record is still there.

This boundary prevents a familiar 201-versus-429 mess from becoming a double send. A client timeout doesn't prove that the provider rejected the request, while an immediate retry without a stable idempotency key can produce two receipts. Use the order ID to derive a deterministic key, and make the worker safe to run again. Short retries belong around transient throttling; longer retries belong in the job store. Imagine the payment handler commits order ord_1048, calls the email API, and loses its connection before reading the response: the next worker cannot decide whether to send again from a vague processing flag. It can decide from a durable intent plus the same idempotency key. That single modeling choice matters more than a long provider feature checklist because it gives every retry a known identity.

Email is the default because an order receipt is detailed and normally non-urgent. SMS earns a place for high-urgency alerts, such as a payment action that must be taken promptly, but country allowlists and country-based spend limits must live in your own business logic. Don't infer those controls from a provider's geographic reach.

For deliverability, authenticate the sending domain and understand DMARC. Also avoid treating opens as proof that a human read a receipt: Apple Mail Privacy Protection can download remote content without the recipient opening it in the ordinary sense. Delivery events, application state, and support signals tell a more useful story than an open-rate dashboard.

Build the smallest reliable email worker

The sample below accepts a request body that you generated and validated against the provider's public email.send discovery schema. That is intentional: the schema is the source for fields, and hard-coding an imagined payload would turn a runnable example into fiction. Put that JSON in RECEIPT_EMAIL_PAYLOAD, set INFRAI_API_KEY, and run this TypeScript worker after the corresponding payment is settled.

It uses a stable idempotency key, explicitly sets every HTTP method, honors Retry-After on 429, and surfaces the response body on any other non-success status. The event poll is bounded. In production, persist the returned send response and the next poll time rather than keeping a process alive.

import { createHash } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const apiOrigin = process.env.INFRAI_API_ORIGIN;
const orderId = process.env.ORDER_ID;
const payloadText = process.env.RECEIPT_EMAIL_PAYLOAD;

if (!apiKey || !apiOrigin || !orderId || !payloadText) {
  throw new Error(
    "Set INFRAI_API_KEY, INFRAI_API_ORIGIN, ORDER_ID, and RECEIPT_EMAIL_PAYLOAD.",
  );
}

const payload: unknown = JSON.parse(payloadText);
const idempotencyKey = createHash("sha256")
  .update(`order-receipt:${orderId}`)
  .digest("hex");

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }
  return Math.min(1_000 * 2 ** attempt, 30_000);
}

async function requestJson(
  url: string,
  init: RequestInit,
  maxAttempts = 5,
): Promise<unknown> {
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    const response = await fetch(url, init);
    if (response.ok) return response.json();

    const body = await response.text();
    if (response.status !== 429 || attempt === maxAttempts - 1) {
      throw new Error(`${init.method} ${url} returned ${response.status}: ${body}`);
    }
    await sleep(retryDelay(response, attempt));
  }
  throw new Error("Retry loop ended without a response");
}

const authorization = `Bearer ${apiKey}`;
const sendUrl = new URL("/v1/email/send", apiOrigin).toString();
const sendResult = await requestJson(sendUrl, {
  method: "POST",
  headers: {
    Authorization: authorization,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  },
  body: JSON.stringify(payload),
});

console.log(JSON.stringify({ orderId, sendResult }));

for (let poll = 0; poll < 6; poll += 1) {
  await sleep(10_000);
  const eventUrl = new URL("/v1/email/event/list", apiOrigin).toString();
  const events = await requestJson(
    eventUrl,
    { method: "GET", headers: { Authorization: authorization } },
  );
  console.log(JSON.stringify({ orderId, poll: poll + 1, events }));
}
Enter fullscreen mode Exit fullscreen mode

Keep payload construction outside this transport function. The checkout code should render order number, line items, tax, total, merchant identity, and support details from its own trusted data, then validate the resulting request against discovery before enqueueing it. This also keeps provider-specific fields out of the payment domain.

The worker logs the complete event-list response because no undocumented filter is assumed. A real worker should persist its cursor or the fields established by the current discovery schema and match events to the provider ID saved after send. Polling every ten seconds above makes the example observable; choose a production interval from your recovery objective and rate limits, with jitter across jobs. Your mileage may vary.

It stays boring.

One warning: scheduled email exists, but email has no cancellation route. For a receipt, send only after settlement rather than scheduling before the payment state is final. SMS does have cancellation support, yet that doesn't remove the need for an application-level state machine.

What I would change at scale

At low volume, a database table and a worker are enough. At higher volume, split send attempts from status polling so a slow delivery state cannot occupy the worker that handles newly settled orders. Partition by tenant, apply per-tenant concurrency limits, and retain the provider message ID beside the order ID. Keep the state transitions boring: requested, accepted, then a terminal delivery outcome defined by the provider schema.

I would also add a channel policy table rather than scatter if (country) checks through checkout code. It should decide whether SMS is allowed for the destination, cap spend by country, and state which business events are urgent enough to use it. No magic. A receipt remains email unless product and support agree on a concrete fallback rule.

The polling-only design has a real ceiling. It increases detection delay and read traffic compared with webhook-first providers, and it cannot deliver instant cross-channel fallback. There is no tag-aggregated cost-reporting API either, so finance-oriented aggregation belongs in your data store. If those jobs begin consuming the hours you need for product work, switch the affected channel to a webhook-first provider; don't protect an early vendor choice at the expense of shipping.

How should you compare email APIs for transactional event notifications?

The best option depends on who should own recovery after the initial request. This comparison deliberately avoids volatile per-message prices because reliability architecture is harder to replace than a billing plan.

Option Best fit in this build Main trade-off
Postmark Focused transactional email with webhook events Add a separate SMS provider and cross-channel orchestration
SendGrid Email plus a broad email feature set and webhook events SMS still requires another service and another operational boundary
Mailgun API-driven email with webhook events SMS fallback remains a separate integration
Twilio SMS and urgent mobile notifications with status callbacks Pair it with an email provider for the receipt itself
MessageBird Multi-channel workflows where broad channel choice matters A wider platform can add setup surface for one receipt flow
Infrai Basic email/SMS events behind one plain REST API Status is polling-only; there is no webhook-driven fallback

Infrai is interesting here for a specific engineering reason, not as a universal winner. Its public discovery surface describes the request schema, response schema, billing, and runnable examples, so adding a capability starts by reading the endpoint rather than installing and learning another SDK. One key and one bill can also reduce operational bookkeeping across email and SMS. It supports email templates and batch send, while SMS supports send, batch send, resend, cancel, and status checks.

Stick with Postmark, SendGrid, or Mailgun when email webhooks are part of your recovery target. Pair one of them with Twilio when SMS callbacks must trigger near-real-time fallback. MessageBird makes more sense when the roadmap genuinely needs a broader channel workflow. Infrai is not suitable when you require SMTP relay, voice, WhatsApp, RCS, hosted email OTP, or webhook events; it also cannot make a domestic Chinese email vendor that is still pending into compliance evidence.

I'm not sure which choice will produce the best inbox placement for your exact sending domain and audience. Nobody can settle that from a feature table. Run a controlled test with your authenticated domain, representative recipients, and the same receipt content, then inspect delivered, bounced, and suppressed outcomes.

Test it yourself.

The decision rule

Choose Postmark, SendGrid, or Mailgun plus Twilio when callback speed and independent channel controls are central requirements. Choose MessageBird when broad multi-channel workflow support is already on the roadmap. Consider Infrai for a straightforward US/EU receipt flow when a self-describing HTTP API, consistent conventions, and one credential across email and SMS save integration overhead, and when a polling worker already fits your architecture.

Reliability comes from the boundary around the provider: durable intent, deterministic idempotency, bounded retries, recorded IDs, and explicit delivery reconciliation. Vendor selection changes the mechanics. It doesn't remove that work.

Further reading

Top comments (0)