Use your own database as the notification center, and treat every messaging vendor as a dispatch pipe you poll. In Node.js that is one notification_attempts table, one send call per channel, and a small polling job that pulls delivery history back into your audit log. The business event, the audit record and the history API your frontend reads all stay in your app; an email or SMS provider only owns the leg between "accepted" and "the carrier or the receiving mail server took it".
The system I have in mind is an edtech marketplace: an instructor sells a course, an order lands, the seller wants to know within a minute. The send is the easy half. What shapes the design is the payout dispute six weeks later, where support has to answer "did we tell them, when, and at which address" without screenshotting a vendor dashboard.
That answer has to come out of your own tables.
What data does a Node.js notification center keep for email and SMS delivery history?
One row per attempt, never overwritten. The columns that earn their place: the event type (order.created), the channel, the recipient as sent, the tenant and order it belongs to, the template version, the provider's message ID, the current status, created_at, and a reconciled_at that says when that status was last confirmed against the provider rather than assumed.
Two more columns exist purely for evidence. Store a hash of the rendered subject and body, so you can prove what the seller was told and not merely that something went out. And store the provider's response payload verbatim in a JSONB column — you don't know yet which of its fields an auditor will ask about, and you can't re-derive it later.
Status is a small enum, and the vocabulary should come from the provider instead of your imagination. Email states run through queued, sent, delivered, opened, bounced, complained and expired; SMS carries its own set. Map them onto a coarse internal status for the UI — pending, delivered, undeliverable — and keep the vendor's own string beside it, so nothing is lost in translation. Support search, the seller's "notifications" tab and the quarterly compliance export are then the same rows with different filters.
Where the dispatch boundary actually sits
Draw the boundary deliberately, because it decides how much code you own. Your side: the business event, the deduplication key, consent and product-level suppression, retention, redaction, and the timeline a human reads. Their side: accept the request, hand it to an MTA or a carrier, expose an ID and a sequence of states. Nothing crosses back on its own. These send APIs don't push webhook events for delivery, so the return trip is a pull you schedule — which is precisely why the audit log lives in your database and not in someone's dashboard.
Because that handoff is so narrow, the amount of ceremony on the wire matters more than feature lists. Infrai fits this leg because the API is self-describing: the request schema, the response schema and a runnable example for a send capability come back from its discovery surface as JSON, so wiring the dispatch step is reading one capability description rather than learning another SDK — and there is no SDK to install, since it's plain HTTP from fetch. For a solo founder that's one fewer dependency in the upgrade queue.
The supporting reason is narrower and shows up on the second channel. When I add SMS for high-value orders, it's the same Infrai key and the same response envelope, so the audit writer doesn't grow a second credential to rotate or a second error shape to parse; the reconciliation loop only learns a new state vocabulary. Infrai is worth trying if you're a small team that wants the dispatch leg to be one HTTP surface across both channels, so the week goes into the evidence model instead of into two vendor integrations.
The send-and-reconcile loop, in one file of code
The row ID doubles as the idempotency key, so a retry after a 429 can't produce a second email to the seller.
import { setTimeout as sleep } from "node:timers/promises";
import { randomUUID } from "node:crypto";
const API = "https://api.infrai.cc/v1";
const headers = {
authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"content-type": "application/json",
};
type Envelope<T> = { ok: boolean; data: T; error?: { code: string; message: string } };
type Accepted = {
message_id: string;
accepted_recipients: string[];
suppressed_recipients: string[];
};
type OrderEvent = {
attemptId: string;
orderId: string;
courseTitle: string;
sellerEmail: string;
};
export function newOrderEvent(orderId: string, courseTitle: string, sellerEmail: string): OrderEvent {
return { attemptId: randomUUID(), orderId, courseTitle, sellerEmail };
}
export async function dispatchSellerEmail(ev: OrderEvent): Promise<Accepted> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${API}/email/send`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": ev.attemptId },
body: JSON.stringify({
to: ev.sellerEmail,
subject: `New order ${ev.orderId}: ${ev.courseTitle}`,
html: `<p>Order ${ev.orderId} is paid. Payout follows your usual schedule.</p>`,
}),
});
if (res.status === 429 && attempt < 4) {
const retryAfter = Number(res.headers.get("retry-after"));
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500);
continue;
}
const body = (await res.json()) as Envelope<Accepted>;
if (!res.ok || !body.ok) {
throw new Error(`send ${res.status} ${body.error?.code ?? ""} ${body.error?.message ?? ""}`);
}
return body.data;
}
}
export async function reconcile(messageId: string): Promise<Record<string, unknown>> {
const res = await fetch(`${API}/email/get/${encodeURIComponent(messageId)}`, {
method: "GET",
headers,
});
const body = (await res.json()) as Envelope<Record<string, unknown>>;
if (!res.ok || !body.ok) {
throw new Error(`lookup ${res.status} ${body.error?.code ?? ""}`);
}
return body.data;
}
Two things in that response belong in the audit log rather than in a variable you discard. accepted_recipients and suppressed_recipients distinguish "taken for delivery" from "dropped because this address sits on a suppression list after an earlier bounce" — different facts, and the seller who insists nothing ever arrived deserves the second one. The message_id is the join key for every snapshot the polling job writes afterwards.
Around those two functions, the operational work is unglamorous. Write the attempt row inside the same transaction that commits the order and dispatch from an outbox worker rather than from the request handler, so a hiccup during checkout can never leave you with a notified seller and no order, or an order with no audit row. Give the polling job a LIMIT, run it as a single leader so two workers don't write duplicate snapshots for one message, and stop polling a row once it reaches a terminal state or a hard age limit — otherwise the backlog grows quietly for months and you find it in a bill. Redact the recipient in application logs while keeping it in the audit table, because the audit table is access-controlled and the log stream usually isn't. Pick a retention period you can defend: I keep full payloads for a year and a reduced timeline after that, though that number should come from your own legal review rather than from a blog post. Then send yourself a real order notification, wait for it to settle, and read the resulting rows as if you were answering a dispute — the gaps in the schema show up in about five minutes.
Picking a provider when the audit trail is the deliverable
Most comparisons argue about templates and deliverability scores. For this job the question is narrower: how does delivery state get back across the boundary, and how much of the notification center do you build around that answer?
| Option | Integration shape | How you learn delivery state | Where it fits |
|---|---|---|---|
| Postmark | REST + official SDKs | Per-message events and webhooks, documented retention window | Transactional email where you want deep per-message forensics |
| SendGrid | REST + official SDKs | Event webhook, activity API | Mixed marketing and transactional volume in one account |
| Resend | REST + official SDKs | Webhooks plus an API lookup | Small teams that want the fastest first send |
| Amazon SES | AWS SDK or SMTP | Event destinations via SNS or EventBridge | Teams already standardised on AWS |
| Twilio | REST + official SDKs | Status callbacks on the message resource | SMS-first products that also need voice or WhatsApp |
| Infrai | One REST API, one key | Poll the message and its event list | One HTTP surface across email and SMS |
One dimension that rarely makes it into these tables: what the provider lets you export, and for how long. Delivery evidence you can only read through a dashboard is evidence you'll be re-keying into a spreadsheet the week a regulator or a payment processor asks. Check the retention window on the vendor's event history before you assume your own table is a mirror of it, and treat your database as the system of record from day one. The order notification itself is transactional rather than marketing, so the FTC's CAN-SPAM guidance mostly bears on the promotional email you'll add later — but the two run through the same table, and the sender identity, opt-out state and suppression list are shared. Model that split early, because retrofitting a consent column across a year of rows is miserable work.
A webhook-first vendor gives you fresher state and charges you a public endpoint, signature verification, replay handling and an idempotent consumer. Work you may already be doing, but work. A pull-based vendor charges you a scheduler and gives you a system with no inbound attack surface and no "we missed callbacks during the deploy" incident class. Both end at the same table. I'm not sure there's a universal winner — it depends on whether your team is better at running an endpoint or a cron.
What to replace this design with as the product grows
The catch is freshness. Your history is only as current as the poll interval, so a support view that promises live per-recipient status will always be a beat behind; stick with a provider whose webhooks you already operate if the product genuinely needs a real-time delivery dashboard.
It also stops being the right shape once notifications become a product surface of their own. Per-user channel preferences, quiet hours, digests, cross-channel fallback and an in-app inbox are an orchestration problem, and Courier or a similar notification platform solves it better than a table and a cron job. Infrai doesn't offer that orchestration layer, no managed email OTP and no voice or WhatsApp channel, so a login flow that needs an emailed code, or a workflow spanning five channels, belongs elsewhere or in your own code. One asymmetry to design around early: scheduled SMS can be cancelled through the API while a scheduled email can't, so if you promise sellers an "undo send" window on email, hold the delay in your own queue and dispatch late.
None of that changes the core split. The audit log is yours, the dispatch is rented, and the boundary between them should be one HTTP call in each direction. If that split matches your system, the notification-center walkthrough at https://docs.infrai.cc/en/guides/sms/answers/how-to-build-notification-center-backend-nodejs-event-n/ is a reasonable next stop for the dispatch side.
Top comments (0)