A notification center backend in Node: outbox, audit log, history API
Event in, queue, send on email or SMS, record every attempt, and expose one polling endpoint your UI can read — with the provider ids that make the audit log honest.
A notification center is a queue, a delivery-attempt table, and a reconciler. The queue takes the event so your request path never blocks on a carrier; the table records one row per attempt with the provider’s message id in it; the reconciler turns those ids back into delivery states on a schedule. Infrai gives you the first and third parts as REST calls on the key you already have — POST /v1/queue/publish, then POST /v1/sms/send or POST /v1/email/send, then a status read per id.
The part nobody warns you about is the join key. If your delivery-attempt row doesn’t store the id the provider returned, your history page can never say more than “we tried” — and “we tried” is precisely the answer that makes support tickets last three days. Everything below is arranged around keeping that id.
Three tables, one of them boring
CREATE TABLE notification (
id text PRIMARY KEY, -- your event id, also the dedupe key
user_id text NOT NULL,
kind text NOT NULL, -- invoice.paid, shipment.delayed, ...
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE delivery_attempt (
id bigserial PRIMARY KEY,
notification_id text NOT NULL REFERENCES notification(id),
channel text NOT NULL, -- 'sms' | 'email'
provider_id text, -- message_id from the send response
state text NOT NULL, -- queued|sent|delivered|failed|error
detail text,
attempted_at timestamptz NOT NULL DEFAULT now(),
settled_at timestamptz
);
CREATE INDEX ON delivery_attempt (notification_id, attempted_at DESC);
CREATE INDEX ON delivery_attempt (state) WHERE settled_at IS NULL;
That partial index is the reconciler’s whole query plan: unsettled attempts, oldest first. The third table — user channel preferences — is a topic of its own, and the short version is that your preference row and the platform suppression list are two different authorities that both get a vote.
The outbox, created once
curl -X POST https://api.infrai.cc/v1/queue/create \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{ "name": "notif-center-outbox", "type": "standard", "max_retries": 5 }'
{
"ok": true,
"data": {
"name": "notif-center-outbox",
"type": "standard",
"max_receive_count": 5,
"visibility_timeout_default": 300,
"message_retention_days": 14,
"dlq_name": "notif-center-outbox.dlq"
}
}
A dead-letter queue is created alongside it, which is where a notification lands after five failed processing attempts. Check it on a schedule; a growing dlq_count is the first symptom of a template that references a variable nobody supplies.
Publishing an event carries an idempotency_key, and it should be your event id rather than a UUID you mint at publish time:
curl -X POST https://api.infrai.cc/v1/queue/publish \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"queue": "notif-center-outbox",
"payload": { "event_id": "evt_9c1", "type": "invoice.paid", "user_id": "usr_8123" },
"idempotency_key": "evt_9c1"
}'
Publish that twice and you get the same message_id back both times, with the queue depth still at 1. A webhook receiver that retries on your 504 is the normal reason this matters.
The worker: consume, send, record, ack
import { setTimeout as sleep } from "node:timers/promises";
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is unset (use your_infrai_api_key locally)");
const H = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
async function api(method, path, body) {
const res = await fetch(`${BASE}${path}`, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw Object.assign(new Error(json?.error?.message ?? `HTTP ${res.status}`), {
status: res.status,
code: json?.error?.code,
});
}
return json.data;
}
// db is any pg-style client: db.query(text, params)
export async function runWorker(db, { queue = "notif-center-outbox", idleMs = 2_000 } = {}) {
for (;;) {
const { items } = await api("POST", "/v1/queue/consume", { queue, max_messages: 10, visibility_timeout: 60 });
if (!items.length) { await sleep(idleMs); continue; }
for (const msg of items) {
const { event_id, type, user_id } = msg.payload;
const prefs = (await db.query("SELECT phone, email, channel FROM prefs WHERE user_id = $1", [user_id])).rows[0];
const channel = prefs?.channel ?? "email";
let attempt = { channel, provider_id: null, state: "error", detail: null };
try {
if (channel === "sms") {
const r = await api("POST", "/v1/sms/send", {
to: prefs.phone,
body: `Invoice paid. Receipt: https://app.example.com/i/${event_id}`,
from: "AcmeBilling",
});
attempt = { channel, provider_id: r.message_id, state: r.state, detail: `${r.segments} segment(s)` };
} else {
const r = await api("POST", "/v1/email/send", {
to: prefs.email,
from: "billing@example.com",
subject: "Your invoice is paid",
html: `<p>Receipt <a href="https://app.example.com/i/${event_id}">${event_id}</a></p>`,
});
attempt = { channel, provider_id: r.message_id, state: "queued", detail: r.from_used };
}
} catch (err) {
attempt.detail = `${err.code ?? "send_failed"}: ${err.message}`;
}
await db.query(
`INSERT INTO delivery_attempt (notification_id, channel, provider_id, state, detail)
VALUES ($1, $2, $3, $4, $5)`,
[event_id, attempt.channel, attempt.provider_id, attempt.state, attempt.detail],
);
await api("POST", "/v1/queue/ack", { queue, message_id: msg.message_id });
}
}
}
Note the ordering: the attempt row is written before the ack. Crash between the two and the message reappears after its visibility timeout, you re-send, and you get a duplicate — which is the honest trade, because the alternative is acking first and losing the record of a message that really went out. Duplicate-but-recorded beats silent-and-forgotten for anything a human will be asked about later.
The reconciler, which is where the history comes from
Neither channel pushes state to you, so a scheduled job re-reads unsettled attempts. SMS is a single GET per id:
curl -s https://api.infrai.cc/v1/sms/status/msg_3RfVn8QsLtC2yEhaWpZk \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Email is an event list, and the message_id query parameter is required — without it the route answers 400 rather than listing everything:
curl -s "https://api.infrai.cc/v1/email/event/list?message_id=msg_FW89oeWakKVGOpIvXCEC7J5a" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"records": [
{ "type": "sent", "recipient": "ops@example.com", "occurred_at": "2026-07-26T02:41:09Z" },
{ "type": "delivered", "recipient": "ops@example.com", "occurred_at": "2026-07-26T02:41:14Z" }
],
"total_count": 2,
"next_cursor": null
}
}
The two channels settle differently, and the reconciler has to know that:
| SMS | ||
|---|---|---|
| Read the state with | GET /v1/sms/status/{id} | GET /v1/email/event/list |
| Shape | one current state | an append-only event list |
| Settles on | delivered, failed, undelivered | delivered, bounced, complained |
| Never settles when | the carrier returns no receipt | the recipient never opens it, which isn’t a delivery state anyway |
| Extra signal | failed_reason | opened, clicked |
| Cost per read | free | free |
Fold the newest event type into state, stamp settled_at, and your audit log now carries a real timeline rather than an intention. Terminal states settle; sent without a delivered follow-up is not terminal and should keep getting re-read until you give up on it, which for SMS is usually a few minutes.
One endpoint for the UI
import { createServer } from "node:http";
export function historyServer(db, port = 8080) {
return createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== "/api/notifications") { res.writeHead(404).end(); return; }
const userId = url.searchParams.get("user_id");
if (!userId) { res.writeHead(400).end(JSON.stringify({ error: "user_id required" })); return; }
try {
const { rows } = await db.query(
`SELECT n.id, n.kind, n.created_at,
a.channel, a.state, a.detail, a.provider_id, a.settled_at
FROM notification n
LEFT JOIN LATERAL (
SELECT * FROM delivery_attempt d
WHERE d.notification_id = n.id ORDER BY d.attempted_at DESC LIMIT 1
) a ON true
WHERE n.user_id = $1 AND n.created_at > now() - interval '30 days'
ORDER BY n.created_at DESC LIMIT 100`,
[userId],
);
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ items: rows }));
} catch (err) {
res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: err.message }));
}
}).listen(port);
}
Your front end polls that, not the gateway. Provider ids stay server-side where they belong.
Queue depth is your health check
curl -s https://api.infrai.cc/v1/queue/stats/notif-center-outbox \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
available_count climbing means the worker is down or too slow; dlq_count above zero means something is poisoning. Both are free reads.
The bill, and where it lands
Verified 2026-07-26: a queue publish is $0.00002, a transactional email $0.000115, and an SMS segment about $0.0075. So the notification center’s own plumbing — queue, status polls, DLQ checks, queue stats — is effectively free, and the cost is entirely the channel you picked, with SMS about 65 times the price of email. Those rates move down over time and campaigns run, so read the current ones:
curl -s https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id=="queue.publish" or .id=="email.send" or .id=="sms.send")
| {id, price_usd: .billing.price_usd, unit: .billing.unit, free: .billing.free}]'
New accounts carry $2 of trial credit, which is a few hundred SMS or many thousand emails.
Limitations worth designing around
There’s no webhook for SMS state, so the reconciler is mandatory rather than an optimisation, and at tens of thousands of unsettled attempts you’ll want batched scheduling rather than one GET per row. GET /v1/sms/events/{id} looks like the richer timeline the audit log wants, but it answered 503 VENDOR_NOT_CONFIGURED on our account in July 2026 — build on GET /v1/sms/status/{id} and treat the event feed as an upgrade. No route publishes X-RateLimit-* or Retry-After headers, so pace the reconciler yourself.
If the notification center is the product — user-facing inbox widgets, digest batching, in-app plus push plus email fan-out with a preference UI included — a dedicated notification platform will hand you more of that than any gateway does, and Twilio’s Notify sits closer to that shape than a raw send route. MessageBird is worth a look if your traffic is mostly European and you want carrier-level routing controls. The reason to build it here is that the queue, both channels and the spend report share one credential and one invoice, so adding push or a digest later is another route on the same key rather than another vendor on the same spreadsheet.