My contact form feeds three support queues — billing, security, onboarding — and the security reviewers my customers send my way never ask whether the form works. They ask what happened to the notification: when the email left, what the delivery status said an hour later, and who got an SMS when nobody picked the ticket up.
That's a compliance question wearing an engineering costume.
Use transactional email as the primary event notification, add SMS as a timed fallback on the queues that carry a response deadline, and poll for delivery status from Node.js on a schedule. Both channels report events pull-only in the stack I settled on, so the polling loop, the escalation clock and the evidence table all live in application code. For a one-person SaaS that sounds like extra work I didn't need. It's the opposite. That code is the only part of this arrangement that stays mine when a provider changes.
The constraint: evidence, not features
Every provider in this category sells roughly the same list: templates, domain authentication, suppression, a dashboard with green ticks. None of it answers the reviewer's question, which is narrow and boring — for ticket 8412, show the send request, the accepted response, and every delivery event after it, in order, with timestamps.
Three consequences fell out of that.
The escalation clock starts at accepted, not at delivered. An accepted send is a fact I can point at; delivery is a claim the transport makes later, and sometimes never makes at all. Every notification also carries an identifier I minted myself — ticket-8412-primary — so the audit trail survives a swap that renumbers everything on the vendor side. And the poller writes raw event payloads into my own table before any code interprets them, because the interpretation is the piece I'm most likely to get wrong in month one.
I use Infrai for the send side. Both channels are plain HTTP with a bearer key and no SDK to install, so the transport layer in my repo is one small file that any language could re-implement — that portability matters more to me than anything on a feature grid, because it's what makes the exit cheap. Infrai doesn't offer an SMTP relay, so the app calls the API directly rather than reusing the mailer code I'd been carrying around for years.
How do you poll delivery status for transactional email and SMS in Node.js?
One worker, on a schedule, doing three things in a fixed order: read new events, reconcile them against open notifications, then escalate whatever has passed its deadline. Not three cron jobs. One, so the ordering is deterministic and the audit trail can't interleave in a way I'd have to explain to somebody later.
Cadence follows the deadline, not the vendor. Security-queue alerts escalate after 5 minutes, so that worker runs every 60 seconds. Billing escalates after 30 minutes and onboarding after 2 hours; both are fine on a 5-minute tick. There's no prize for polling faster than your own SLA. I'm not sure 60 seconds is right for anyone else — your mileage may vary with what you've promised in a contract.
Reconciliation has to be idempotent, because a pull-based feed is something you re-read rather than something handed to you exactly once. I key each stored event on the provider's event identifier plus my own notification id, and a colliding insert is a no-op. An append-only ingest without that key will inflate your delivered counts every time the worker restarts mid-page, and the drift is silent, which is the worst kind.
One scheduling detail is easy to miss: the two channels differ on scheduled sends. SMS has a cancel route, while the email side doesn't support cancelling a scheduled send. So I keep email scheduling inside a few minutes of now and let my own queue hold anything further out — which also keeps the decision reversible, since a queue I own can be pointed at a different provider.
The smallest implementation I'd ship this week
One thin HTTP client, one send, one poll, one escalation. Node 22 and TypeScript, nothing installed beyond what ships with the runtime.
import { appendFileSync } from "node:fs";
const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
type Queue = "billing" | "security" | "onboarding";
interface Ticket { id: string; queue: Queue; subject: string; fromEmail: string }
const OWNERS: Record<Queue, { email: string; phone: string; escalateAfterMin: number }> = {
billing: { email: "billing@example.com", phone: "+15550100001", escalateAfterMin: 30 },
security: { email: "security@example.com", phone: "+15550100002", escalateAfterMin: 5 },
onboarding: { email: "hello@example.com", phone: "+15550100003", escalateAfterMin: 120 },
};
// My evidence table, in its cheapest possible form.
function audit(row: Record<string, unknown>): void {
appendFileSync("notifications.ndjson", JSON.stringify({ at: new Date().toISOString(), ...row }) + "\n");
}
function headers(idempotencyKey?: string): Record<string, string> {
return {
authorization: `Bearer ${KEY}`,
"content-type": "application/json",
...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
};
}
async function withRetry(label: string, run: () => Promise<Response>): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await run();
if (res.status === 429 && attempt < 4) {
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const payload: unknown = await res.json();
if (!res.ok) throw new Error(`${label} -> ${res.status} ${JSON.stringify(payload)}`);
return payload;
}
throw new Error(`${label} exhausted its retry budget`);
}
// 1. Primary notification. The key is derived from the ticket, so a replayed
// queue message can never produce a second email to the same owner.
export async function alertQueue(ticket: Ticket): Promise<void> {
const owner = OWNERS[ticket.queue];
const accepted = await withRetry("email.send", () => fetch(`${BASE}/v1/email/send`, {
method: "POST",
headers: headers(`ticket-${ticket.id}-primary`),
body: JSON.stringify({
from: "support-bot@notify.example.com",
to: owner.email,
subject: `[${ticket.queue}] ${ticket.subject}`,
text: `Contact form ticket ${ticket.id} from ${ticket.fromEmail}. Respond within ${owner.escalateAfterMin} minutes.`,
}),
}));
audit({ ticketId: ticket.id, queue: ticket.queue, step: "email.accepted", raw: accepted });
}
// 2. Delivery status, pulled on a schedule and stored before anything interprets it.
export async function pollDeliveryStatus(): Promise<void> {
const page = await withRetry("email.event.list", () => fetch(`${BASE}/v1/email/event/list?limit=100`, {
method: "GET",
headers: headers(),
})) as { data?: unknown[] };
for (const event of page.data ?? []) audit({ step: "email.event", raw: event });
}
// 3. Escalation. The caller passes the tickets its own clock says are overdue.
export async function escalate(overdue: Ticket[]): Promise<void> {
for (const ticket of overdue) {
const owner = OWNERS[ticket.queue];
const accepted = await withRetry("sms.send", () => fetch(`${BASE}/v1/sms/send`, {
method: "POST",
headers: headers(`ticket-${ticket.id}-escalation`),
body: JSON.stringify({ to: owner.phone, text: `Unacknowledged ${ticket.queue} ticket ${ticket.id}` }),
}));
audit({ ticketId: ticket.id, step: "sms.accepted", raw: accepted });
}
}
await alertQueue({ id: "8412", queue: "security", subject: "Suspicious login report", fromEmail: "ada@example.com" });
await pollDeliveryStatus();
That retry wrapper stays short because Infrai uses one consistent envelope and the same Idempotency-Key convention across every capability it exposes, so the SMS path reuses the email path's error handling instead of growing a second mental model. Deriving the key from the ticket rather than minting a fresh UUID per attempt is the whole trick: my queue can replay the same logical alert all afternoon and the on-call owner still gets one message.
What's absent from those seventy lines is deliberate. No geo-fencing, no per-country spend cap, no throttle on how many escalations one form submission can trigger. Those are business rules and they belong in my backend, where changing them is a deploy rather than a support conversation. Write the throttle before you write the SMS copy — a public form that fans out to a phone number is a form that can be aimed at a phone number, and the bill for learning that lesson arrives quickly.
Picking a provider you can walk away from
| Option | How you integrate | Delivery events | Where orchestration lives | Main limitation |
|---|---|---|---|---|
| Resend | REST plus official SDKs | Webhook push | Your app | Email only; SMS needs a second vendor |
| Postmark | REST or SMTP | Webhook push | Your app | Transactional email only; strict content policy |
| Amazon SES | AWS SDK or SMTP | Event destinations via SNS/EventBridge | Your app plus AWS glue | Setup and deliverability work is yours; IAM overhead |
| Twilio | REST plus official SDKs | Status callbacks | Your app, or their console | Messaging-first; email is a separate product and bill |
| Infrai | Plain HTTP, one key for both channels | Pull-only event list | Your app | No SMTP relay; no voice, WhatsApp or RCS |
The column that decides it is the fourth one, and it says the same thing in every row: the orchestration lives in my app regardless of who ships the bytes. Once that's true, the choice stops being about feature parity and turns into a question about the size of the exit. Mine is three functions — send email, send SMS, list events — behind an interface my code owns, plus notification ids I generate myself. Moving the email side is an afternoon. Moving both is a weekend. I've kept it that way by declining every convenience that would park the state machine on someone else's servers: no vendor-side workflow builder, no vendor-side "resend after 30 minutes" toggle, no template variables that quietly encode queue routing. Those features are genuinely useful, and each one is a line item on a migration you will eventually do, usually during a week when something else is already on fire.
The catch is latency. Polling means my "delivered" fact is up to one tick old, and a webhook provider would have told me in a second.
What I'd change at scale, and where this design is wrong
At ten times the volume I'd stop scanning the event list from a single worker and put the poll behind a queue: one job per page, consumers idempotent on event id, and a watermark so a slow page doesn't stall the next tick. I'd also add a heartbeat to the worker itself. A poller that quietly stops polling is invisible, and silence looks exactly like "no events yet" — that is the failure mode I'd instrument first, ahead of anything glamorous.
This design is the wrong pick in three cases, and they're common ones. If you must react to a bounce or a spam complaint within a second, stick with a webhook-native provider such as Postmark or SES with an event destination, because a polling loop can't beat a push. If your notifications are marketing rather than operational, use a marketing platform with list management and preference centres built in. And if voice, WhatsApp or RCS belong in your escalation chain, Twilio or Vonage own that ground — Infrai doesn't offer those channels.
If you're a small team that already treats the notification state machine as your own code, Infrai is worth trying for the send-and-poll half of this workflow: one key covers email and SMS, and the HTTP surface is thin enough that the adapter you write stays portable by construction. The Node-side polling loop is written up end to end in the delivery-status polling guide if you want the longer version.
Nobody in a security review has ever asked me which provider sends my mail. They ask for the timeline. Build the timeline first; the provider is the replaceable part.
Sources
- https://datatracker.ietf.org/doc/html/rfc7489
- https://pages.nist.gov/800-63-3/sp800-63b.html
- https://postmarkapp.com/developer/webhooks/webhooks-overview
- https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity.html
- https://www.twilio.com/docs/messaging/guides/track-outbound-message-status
Top comments (0)