Short answer: keep the order-receipt template and delivery policy in the backend, put email and SMS behind small adapters, and let the payment-settled event choose the channel. That boundary is more important than picking the cheapest API because it keeps the receipt testable, makes retries predictable, and prevents a delivery provider from becoming the owner of checkout logic.
For this build, email is the normal receipt and SMS is an explicit alternate channel. A customer-engagement platform enters the decision only when non-engineers need to own journeys, timing, and segmentation. The constraint that changes the choice is template ownership: a receipt is part of the order system's behavior, not a campaign asset.
Keep it boring.
How should a Node.js backend compare email APIs, SMS APIs, and event notification platforms?
Compare the ownership boundary before comparing feature lists. A direct email API or SMS API usually fits behind a narrow delivery port: the application renders content, supplies the recipient, and records the outcome. A customer-engagement platform moves more decisions outside the application. That can be useful for lifecycle messaging, but it also means the team must decide which system owns templates, audience rules, suppression policy, and the audit trail for a transactional receipt.
I use four questions for the first pass:
- Can the backend render the exact receipt from versioned order data?
- Can a retry reuse one event ID without producing a second customer-visible receipt?
- Can delivery metadata be stored without putting an email address or phone number into logs?
- Can the same contract run in the required US or EU deployment without moving template logic into a second control plane?
Those questions create a fair comparison between a direct API and a broader platform. Put Customer.io, Braze, and OneSignal through that same contract if they are on the shortlist; don't infer that similarly named features have the same ownership model. Resend's public introduction is likewise a starting point for inspecting an email adapter, not evidence that one category wins. Vendor documentation can answer implementation questions. Only a proof with the team's own payload, region requirements, and retry policy answers the architecture question.
The word "cheapest" needs a workload attached to it. Build a monthly model from attempted email sends, attempted SMS sends, retries, regional traffic, retained event data, and engineering time spent maintaining templates. I'm not sure any static comparison survives a meaningful traffic change; a quote and a small integration test resolve that uncertainty better than a ranking table.
The constraint that changed the design
The payment service already knows when an order becomes settled. It should emit a stable event containing an event ID, order ID, locale, receipt destination, channel preference, line items, and totals. It should not know how an email provider represents a template or how an SMS provider names a message. The notification worker consumes that event, validates it, renders the backend-owned template, and invokes one delivery port.
That gives the system three layers:
| Layer | Owns | Must not own |
|---|---|---|
| Order domain | Payment-settled state and immutable receipt data | Provider payloads |
| Notification policy | Channel choice, template version, rendering, idempotency | Payment transitions |
| Delivery adapter | Authentication, request mapping, response normalization | Receipt wording or business rules |
This split is deliberately strict. Suppose the worker receives event evt_01JQ193, renders template version receipt-v3, sends it, then loses its queue acknowledgement. The next delivery should find the stored event ID and return the existing result. It shouldn't render a newer template, pick another channel, or send again. A generic queue may provide redelivery, but the application still needs an idempotency record around the customer-visible side effect.
One event, one receipt.
The ordering matters in less obvious cases too. If the first attempt is rate-limited before acceptance, the stored record must allow another attempt; if the destination is invalid, repeating the identical request only adds noise. If the adapter accepts the message but its response is lost, blindly retrying can create ambiguity unless the adapter receives the same idempotency key. The worker therefore needs states that distinguish an attempt from an accepted delivery, plus a reconciliation path for an outcome that is not yet known. This is why I wouldn't hide delivery behind a fire-and-forget helper. Ten lines saved at the call site can erase the evidence needed to decide whether a retry is safe.
Template ownership also determines the deployment workflow. Backend-owned templates can be reviewed beside the order schema and tested with fixtures before release. Platform-owned templates can let an operations or marketing team change content without a backend deployment. Neither is universally correct. For a payment receipt, I favor the former because the message mirrors domain data and correctness beats editing convenience. For a multi-step win-back journey, that preference can reverse.
The catch is real: backend ownership makes engineers responsible for localization, preview tooling, escaping, accessibility checks, and template releases. It is not suitable when a non-engineering team must change journeys daily and engineering cannot support that workflow. In that case, keep the transactional receipt in the backend and place campaign orchestration in a customer-engagement platform, with an explicit event contract between them.
The smallest working TypeScript implementation
The useful abstraction is a delivery result, not a universal provider object. Provider-specific response fields stay in the adapter; the worker stores only the fields its retry and audit logic need.
type Channel = "email" | "sms";
type PaymentSettled = {
eventId: string;
orderId: string;
channel: Channel;
destination: string;
currency: string;
totalMinor: number;
};
type Message = {
destination: string;
subject?: string;
body: string;
};
type DeliveryResult = {
accepted: boolean;
externalId?: string;
reason?: "invalid_destination" | "rate_limited" | "rejected";
};
interface DeliveryPort {
send(message: Message, idempotencyKey: string): Promise<DeliveryResult>;
}
interface ReceiptStore {
find(eventId: string): Promise<DeliveryResult | undefined>;
save(eventId: string, result: DeliveryResult): Promise<void>;
}
function renderReceipt(event: PaymentSettled): Message {
const amount = new Intl.NumberFormat("en-US", {
style: "currency",
currency: event.currency,
}).format(event.totalMinor / 100);
if (event.channel === "sms") {
return {
destination: event.destination,
body: `Order ${event.orderId} paid. Total: ${amount}.`,
};
}
return {
destination: event.destination,
subject: `Receipt for order ${event.orderId}`,
body: `Payment settled for order ${event.orderId}. Total: ${amount}.`,
};
}
async function sendReceipt(
event: PaymentSettled,
ports: Record<Channel, DeliveryPort>,
store: ReceiptStore,
): Promise<DeliveryResult> {
const previous = await store.find(event.eventId);
if (previous) return previous;
const result = await ports[event.channel].send(
renderReceipt(event),
event.eventId,
);
await store.save(event.eventId, result);
return result;
}
The code is intentionally missing a vendor SDK. Each adapter may use fetch, an SDK, or an internal gateway, but none of that leaks into sendReceipt. It also avoids a misleading fallback: a rejected email does not automatically authorize an SMS. Channel consent and destination verification belong in policy evaluated before this function receives the event.
I would test this with a table of fixed events: email and SMS rendering, zero-decimal and two-decimal currencies, an existing idempotency record, an invalid destination, a rate-limit result, and a rejected delivery. A fake DeliveryPort makes those tests deterministic. The contract is small enough that an adapter conformance suite can run against every candidate during evaluation.
If an AI agent can initiate a notification, expose this same narrow operation as a tool with a schema rather than giving the model raw delivery credentials. Anthropic's tool-use guide describes tools in terms of a name, description, and JSON Schema input. The application should still validate the event and enforce channel policy after the tool call; a well-formed argument is not authorization.
What I would change at scale
First, I would separate acceptance from delivery. The worker should record whether an adapter accepted the message, while later delivery events update a distinct state. A dashboard that collapses queued, accepted, delivered, rejected, and user-reported outcomes into one "sent" counter hides the exact failure an operator needs to diagnose.
Second, I would add an outbox beside the payment transition. The order transaction writes the settled state and an event record together; a relay publishes that record to the notification worker. This closes the gap where payment commits but process termination occurs before publication. The notification side still deduplicates by eventId, because publication and consumption can both repeat.
Third, I would define a small error taxonomy across adapters. invalid_destination is terminal until customer data changes. rate_limited is retryable with delay. rejected needs a recorded reason and an operator-visible outcome. Don't store raw provider bodies forever just because they're available — normalize what the system needs, redact destinations in logs, and keep sensitive receipt content out of metrics labels.
Observability should follow the event across all three layers. I want the event ID, order ID, template version, chosen channel, adapter name, attempt number, normalized result, and latency in structured records. I don't want the full message body. Alert on the rate of terminal outcomes and on the age of the oldest unprocessed receipt, not just worker CPU or queue depth. A quiet worker can still be failing customers.
At larger volume, template rollout needs its own discipline. Pin a template version to the event, render golden fixtures in CI, and canary a new version using internal destinations before widening it. For US and EU operation, document where event data, template data, logs, and delivery metadata are processed and retained. Region labels in a sales page aren't a data-flow diagram; ask each candidate to trace the exact path.
The trade-off and final decision rule
Choose a direct email or SMS API when the backend team owns transactional content, wants a thin adapter, and can build the surrounding retry, consent, preview, and observability workflow. Choose a customer-engagement platform when journey editing, segmentation, and non-engineer ownership outweigh the value of keeping every decision in code. A mixed design is often the cleanest boundary: backend-owned receipts on one path, campaign events on another.
The boundary stays visible.
Don't choose by the number of channels on a feature page. Run one settled-order fixture through each candidate and score time to first accepted call, adapter code size, template diffability, idempotency behavior, normalized error handling, regional data flow, and the workload-specific cost model. Record disqualifiers before scoring so a low price cannot compensate for a broken ownership requirement.
For this order-receipt system, the decision is backend-owned templates behind email and SMS delivery ports. I would revisit it only if template editors become the bottleneck or the receipt expands into a journey whose timing and audience rules genuinely belong outside the order service.
Top comments (0)