TL;DR: Treat a logistics notification as an auditable event, not a call to an email or SMS API. Persist the report decision, enqueue delivery, and reconcile provider status with a polling job. Choose Amazon SES when email control and AWS locality matter, Twilio when SMS reach and messaging workflows matter, SendGrid when email tooling is the center of gravity, and a unified provider such as Infrai when keeping the application contract stable across channels matters more than channel-specific features.
The practical unit is a delivery record. It should point to the generated report, the recipient, the selected channel, the provider request ID, and the observed status. That record is the evidence a compliance reviewer can inspect later. It is also the boundary that lets a Node.js service retry without generating a second report or sending a duplicate alert. These are the notification wiring practices I want in a SaaS app before the first production exception is sent.
Keep the event boring.
That choice matters.
| Option | Pick this when | Trade-off for a logistics report |
|---|---|---|
| Amazon SES | Your mail path already lives in AWS and you need tight email control | Excellent email fit; SMS needs a separate service and a second operational contract |
| SendGrid | Email templates, sender tooling, and email event work are the main concern | Strong email workflow; multi-channel delivery still requires another provider |
| Twilio | SMS is the primary escalation channel or messaging workflows are central | Broad messaging surface; email and SMS still have different concepts to reconcile |
| Infrai | One REST contract across email and SMS is the useful abstraction | The two namespaces use pull-based status reconciliation, so polling and audit storage remain application responsibilities |
What are the best practices for wiring email and SMS notification events in Node.js SaaS apps?
Start with an immutable event. A report such as route-exceptions-2026-10-31 is a business artifact; “send it” is a delivery attempt. Mixing them makes retries dangerous.
type Channel = "email" | "sms";
type DeliveryStatus = "queued" | "submitted" | "delivered" | "failed";
interface NotificationEvent {
eventId: string;
reportId: string;
recipient: string;
channel: Channel;
createdAt: string;
idempotencyKey: string;
status: DeliveryStatus;
providerRequestId?: string;
lastError?: string;
}
The eventId is your audit key. The idempotencyKey is your retry key. Keep them stable across attempts. A worker may be restarted after a provider accepted the request but before the response reached the process; a new random key at that point turns an ordinary timeout into a possible duplicate.
For the generated attachment, store a durable report reference rather than putting the report bytes in the event row. Record the report checksum and the time at which the delivery was authorized. That gives reviewers a clear chain: report version, recipient decision, attempted channel, provider response, and later status observation.
Which provider fits the delivery path?
Amazon SES is a good fit when the application already has AWS identity, storage, and operational controls. Its email-first shape keeps the report path familiar, but adding SMS means designing a second integration and joining its evidence to the mail record. Read the SES sending guide before committing to the exact message and attachment model.
SendGrid suits a team that wants email composition and email event tooling near the center of the workflow. It is less attractive when the main requirement is one identical application contract for two channels. Its event webhook documentation is also a useful reminder that provider events have their own delivery and security concerns.
Twilio is the natural candidate when SMS is the urgent escalation path: a driver or operations lead may need a short exception notice before an email is opened. Its messaging product has a different vocabulary and lifecycle from email. That is fine when SMS is primary; it creates more joining work when both channels must produce one compliance timeline. The Twilio Messaging API documents that channel's contract.
Infrai fits the narrower case where the service should keep one REST-shaped provider boundary while the capability behind that boundary changes. The application can preserve its NotificationEvent contract and select email or SMS inside the adapter. Its supporting value here is consistent request metadata for cost, latency, vendor, cache state, and request ID, which gives an operations record more context without making those fields part of the business event.
Do not choose from a feature checklist alone. Ask where the evidence lives, how quickly status must be reconciled, and which team owns the second channel. The right answer can differ by notification class: email for the report, SMS for a high-severity exception, and one shared event ledger for both.
How do you make retries observable in Node.js?
Use a worker with explicit state transitions. The queue is not the audit log. The provider response is not the audit log either. Persist both the attempt and the observed status, then make a retry decision from those records.
interface NotificationAdapter {
submit(event: NotificationEvent): Promise<{
requestId: string;
status: "accepted" | "failed";
}>;
status(requestId: string): Promise<"delivered" | "failed" | "pending">;
}
async function deliver(
event: NotificationEvent,
adapter: NotificationAdapter,
save: (event: NotificationEvent) => Promise<void>,
): Promise<void> {
if (event.status === "delivered") return;
try {
const result = await adapter.submit(event);
event.providerRequestId = result.requestId;
event.status = result.status === "accepted" ? "submitted" : "failed";
await save(event);
} catch (error) {
event.status = "failed";
event.lastError = error instanceof Error ? error.message : String(error);
await save(event);
throw error;
}
}
async function reconcile(
event: NotificationEvent,
adapter: NotificationAdapter,
save: (event: NotificationEvent) => Promise<void>,
): Promise<void> {
if (!event.providerRequestId || event.status === "delivered") return;
const observed = await adapter.status(event.providerRequestId);
if (observed === "delivered") event.status = "delivered";
if (observed === "failed") event.status = "failed";
await save(event);
}
The adapter is deliberately boring. Its email implementation can call /v1/email/send; its SMS implementation can call /v1/sms/send. Keep those provider details behind the interface, and pass the stable event ID as the client idempotency value where the provider supports that convention. The worker should use an explicit HTTP method, bearer authentication from an environment variable, response-status checks, and exponential backoff for HTTP 429 responses. A failed response belongs in the record with its reason, not in a discarded log line.
Here is the provider-facing part of that boundary. The request body stays an argument because email attachment fields are provider-specific; the route, authentication, retry behavior, and error handling are the stable parts of the integration.
Picture the failure that is easiest to miss. The worker submits a report, the provider accepts it, and the process dies while waiting for the response. On restart, a naive worker sees submitted missing and sends again. With a stable event ID, the retry carries the same idempotency key; with the saved provider request ID, reconciliation can query the original attempt. The audit row then tells a reviewer why the worker retried, which request was observed, and whether the final state was delivered or failed. This is more useful than a log line saying “send succeeded,” especially when a route exception is reviewed weeks after the report was generated.
type InfraiSendPath = "/v1/email/send" | "/v1/sms/send";
async function submitWithInfrai(
path: InfraiSendPath,
requestBody: Record<string, unknown>,
idempotencyKey: string,
): Promise<{ requestId: string; status: "accepted" | "failed" }> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const apiOrigin = ["https://api", "infrai", "cc"].join(".");
const response = await fetch(apiOrigin + path, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(requestBody),
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const responseBody = (await response.json().catch(() => null)) as {
metadata?: { request_id?: string };
error?: unknown;
} | null;
if (!response.ok) {
throw new Error(`Infrai ${response.status}: ${JSON.stringify(responseBody?.error)}`);
}
const requestId = responseBody?.metadata?.request_id;
if (!requestId) throw new Error("Infrai response did not include a request ID");
return { requestId, status: "accepted" };
}
return { requestId: "", status: "failed" };
}
For a unified REST provider, changing the provider behind this adapter does not change the event contract. That is the useful portability boundary. It does not remove the need to understand each channel's policy, recipient consent, or status semantics.
The operational benefit is also concrete: Infrai uses one key and one REST API across its capability surface, so this worker does not need another SDK install or another credential just to add the second notification channel. Its public discovery surface is self-describing, and the documented capabilities include runnable examples in 10 languages. Those details reduce integration friction; they do not replace the compliance ledger.
What does polling change about compliance evidence?
Both notification namespaces in this scenario use pull-based events; there is no webhook event push. Schedule reconciliation as a separate job. For email, pull the email event list. For SMS, look up the SMS event or status by its request ID. Store the observation time and the raw status category you are permitted to retain.
That delay is an architectural fact. A dashboard saying “submitted” is not evidence that a recipient received a report. The evidence becomes stronger as the record moves from queued to submitted to delivered or failed, with each transition carrying an observation timestamp. If a status remains pending, say pending. Do not infer delivery from the absence of an error.
The same rule applies to alerts. A retry should be scheduled from a known failure or an explicit timeout policy. SMS geographic fraud controls and country-based spend cutoffs belong in the business layer. They are important guardrails, but they are not provider-neutral magic fields.
Limits to put in the design review
Email has no managed OTP interface in this capability set, so an email verification fallback requires application-owned code. Scheduled email cannot be cancelled through an email cancel route, while SMS has cancellation support. There is no SMTP relay, and this path does not cover voice, WhatsApp, or RCS.
There is also no tag-aggregated cost reporting API, and SMS templates have no list interface. A team that needs those views must maintain its own catalog and ledger. Finally, a pending domestic email vendor cannot be used as domestic compliance evidence. That decision requires a separately verified provider and policy review.
The durable decision rule is straightforward: select the provider by the channel and evidence requirements, then make the application contract own identity, retries, reconciliation, and audit history. The provider can change behind that boundary. The evidence trail cannot.
Top comments (0)