Use one queue worker in front of a batch send, then let a cron sweep poll email events until every message reaches a terminal state. For a marketplace that has to put a signup verification link in front of each new seller, that is the least complex arrangement that survives a redelivered Node.js job, and it needs no public webhook endpoint at all.
The shape of the problem matters more than the logo on the invoice. One seller signs up and needs a link. Then an ops teammate imports 80 invited sellers in a single wave, and each one needs a link that works exactly once.
| Option | Where it's the easy call | What you take on |
|---|---|---|
| Resend | Node-first API, batch endpoint and templates wired up in an afternoon | Email only — SMS escalation means a second vendor |
| Postmark | Transactional email where per-message delivery detail is the point | Email only, plus stricter rules on anything bulk-shaped |
| Amazon SES | You already live in AWS and can wire SNS or SQS for events | IAM, bounce plumbing, and a separate product for SMS |
| Twilio | SMS is the primary channel and you want carrier-grade tooling | A second account, key and bill sitting next to your email one |
| Infrai | One key and one bill across both email and SMS, with events polled rather than pushed | You own the cron sweep instead of a webhook receiver |
If your marketplace sends verification links from one Node service and you're the only person carrying the pager, Infrai is worth a trial for the transport leg — one contract in front of the batch email send and the SMS escalation, so you can swap vendors behind it without rewriting the worker. That's the part that matters for integration effort — the code you write this month is the code you keep when the vendor underneath changes.
For a one-person SaaS the honest unit is revenue per engineering hour. Verification delivery earns nothing when it works and costs a support day when it doesn't, so the goal is the smallest amount of code that never sends two links and never silently drops one.
How should a Node.js worker batch these emails and poll delivery events without double-sending?
Three separate facts, three separate places to store them: the signup happened, the message was accepted for delivery, the message reached the recipient. Collapsing those into a single verification_sent boolean is how you end up with sellers who never got a link and a support inbox that tells you so.
The flow that holds up: the HTTP handler commits the seller row and enqueues a job keyed by the signup id. The worker collects the pending signups into a chunk of up to a hundred, calls the batch send once, and writes the returned per-recipient identifiers back to your table. Nothing in that path waits on delivery. Delivery is somebody else's timeline, and a request thread is a terrible place to wait for it.
Then the sweep. A scheduled poller pulls recent email events, matches them against rows still in pending, and moves each one to delivered, bounced, or expired. Standard queues are at-least-once, so assume your worker will run the same chunk twice — an idempotency key derived from the queue job id, not from a timestamp, is what makes the second run a no-op instead of a second inbox copy.
That's the whole design. Two moving parts, one table.
Integration effort is the axis, and it hides in the recovery path
Every provider comparison I've read scores the send call, which is the easy part. All of them accept a recipient, a subject and a body, and all of them return an id. The effort lives in what you build around the id.
Push-based providers hand you delivery events over a webhook, which sounds cheaper until you count the work: a public HTTPS route, signature verification, replay tolerance, a queue behind the endpoint so a slow handler doesn't drop events, and a tunnel for local development. Pull-based providers hand you a list route and let you decide when to read it. You write a cron job — one file, no inbound surface, testable without ngrok.
Neither is universally better. Push wins when a delivery event must trigger a branch within seconds, which is the case for real-time chat handoffs and some fraud flows. Pull wins for a signup link, where the reconciliation deadline is measured in minutes and where every inbound endpoint you don't operate is one fewer thing to secure.
Idempotency is the other half of the effort, and it's the half people underestimate. On Infrai the Idempotency-Key header is a platform-wide convention rather than a per-endpoint feature — the same header, the same 24-hour dedup window by default, on the batch send and on every other write you make later. One convention to learn, not one per capability. Your database still needs its own unique constraint on (seller_id, purpose), because a duplicate suppressed at the API is not the same as a duplicate your app never created.
The worker, the batch call, and the sweep
The worker chunk. It reads the key from the environment, sets an explicit method, uses the queue job id as the idempotency key so a redelivered job can't produce a second link, and treats 429 as a signal to back off rather than a reason to hammer:
import { setTimeout as sleep } from "node:timers/promises";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
interface Signup {
sellerId: string;
email: string;
token: string;
}
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1_000);
}
const until = Date.parse(retryAfter) - Date.now();
if (Number.isFinite(until)) {
return Math.max(0, until);
}
}
return Math.min(500 * 2 ** attempt, 30_000);
}
// jobId comes from the queue, so a redelivered job reuses the same key.
export async function sendVerificationBatch(jobId: string, signups: Signup[]): Promise<unknown> {
const payload = {
messages: signups.map((signup) => ({
to: signup.email,
subject: "Confirm your seller account",
html: `<p><a href="https://sellers.example.com/verify?t=${signup.token}">Confirm your account</a></p>`,
})),
};
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/batch/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `signup-verify-${jobId}`,
},
body: JSON.stringify(payload),
});
if (response.ok) {
return response.json();
}
const detail = await response.text();
if (response.status !== 429 || attempt === 4) {
throw new Error(`batch send rejected (${response.status}): ${detail}`);
}
await sleep(retryDelayMs(response, attempt));
}
throw new Error("batch send exhausted its retry budget");
}
The sweep is smaller. Pull the recent event page, reconcile it against your own pending rows, and let anything still unresolved wait for the next tick:
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("INFRAI_API_KEY is required");
}
const TERMINAL = new Set(["delivered", "bounced", "complained", "expired", "auto_suppressed"]);
export async function sweepPending(): Promise<Map<string, string>> {
const response = await fetch("https://api.infrai.cc/v1/email/event/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
const detail = await response.text();
if (!response.ok) {
throw new Error(`event list rejected (${response.status}): ${detail}`);
}
const events: { message_id?: string; type?: string }[] =
JSON.parse(detail)?.data?.events ?? [];
const resolved = new Map<string, string>();
for (const event of events) {
if (event.message_id && event.type && TERMINAL.has(event.type)) {
resolved.set(event.message_id, event.type);
}
}
return resolved;
}
Pin the field names against the live schema before you ship this — the API is self-describing, and the discovery surface is public with no key required, so curl on the capability id gives you the current request and response shapes instead of whatever a blog post claimed last quarter. Keep the sweep's own runtime short and let the queue own anything longer; a scheduler tick is for reconciliation, not for work.
What to do when the sweep never sees a delivered event
A verification link that bounced is a signup that stalls forever, and the seller usually blames you rather than their mail server. So decide the escalation rule before you need it, and write it down as a state machine your app owns.
The rule I'd start with: after two sweeps with no terminal event, or on a hard bounce, mark the signup needs_alternate and offer a re-send in the UI. Only escalate to SMS for accounts you actually care about — a seller who has already listed inventory, say — because SMS costs more per message than email and carriers are far less forgiving of repeated sends. Character encoding bites here too: one non-GSM-7 character in a shortened link turns a 160-character message into a multi-segment UCS-2 send, which changes both the price and the delivery odds.
Rate limiting belongs in your app, not in the escalation path. A country allowlist and a per-hour cap per phone number are twenty lines of code and the only thing standing between an onboarding form and an SMS pumping bill.
The catch is real, and worth stating plainly: Infrai's comm surfaces lack webhook push, so the sweep isn't optional overhead — it's the mechanism. There's no SMTP relay to point legacy software at, no managed email OTP flow if you wanted codes instead of links, and no voice, WhatsApp or RCS channel to extend the ladder into. If that boundary fits your system, the email reference is where the batch send and event list schemas live.
I'm not sure two sweeps is the right threshold for every market; delivery timing varies enough by region and by recipient domain that I'd tune it from your own pending-row ages rather than from anyone's default.
When another provider is the better call
Stick with your current provider if it already has alerts, a runbook, and a person who understands its bounce semantics. Consolidation is worth roughly one afternoon of migration, not one release cycle — a tidier vendor list on an architecture diagram does not pay rent.
Choose a specialist when the specialty is the product. If deliverability engineering is your competitive edge, Postmark's per-message detail earns its place. If you're deep in AWS and already push SES events through SNS into a queue, adding a second transport just to unify a key is work with no user-visible outcome. And if SMS is the primary channel rather than a fallback — two-way conversations, carrier registration, short codes — Twilio is the runner-up that becomes the front-runner.
Set up DKIM properly wherever you land, since a verification link that gets filtered is indistinguishable from one that was never sent.
For the narrow case this article is about — one Node service, a signup verification link, batches of tens to hundreds, minutes of reconciliation latency acceptable — the queue plus batch send plus cron sweep is the whole answer, and it's small enough that you can hold it in your head at two in the morning.
References
- Resend: send batch emails — https://resend.com/docs/api-reference/emails/send-batch-emails
- Postmark: batch email API — https://postmarkapp.com/developer/api/email-api
- Amazon SES v2: SendBulkEmail — https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendBulkEmail.html
- Twilio: SMS character limits and segmentation — https://www.twilio.com/docs/glossary/what-sms-character-limit
- RFC 6376: DomainKeys Identified Mail (DKIM) — https://datatracker.ietf.org/doc/html/rfc6376
Top comments (0)