Bulk welcome mail after a user import: chunking, pacing and retries
A Node 22 pattern for emailing thousands of imported users once and only once: a send ledger, a suppression pass, chunked batch requests and backoff on 429.
Emailing 8,000 freshly imported users is not the same problem as emailing one new signup. The API call is easy; what bites is sending twice because the job crashed at row 4,200, mailing addresses that already bounced somewhere else, and pushing a month of volume through a sender in ten minutes. Infrai’s POST /v1/email/batch/send handles the first part — the rest is ledger, filter and pacing, and that’s most of this page.
Start by making the send idempotent, because every other safeguard assumes you can re-run the job.
A ledger row per recipient, written before the send
The import table gets a send state. One row per address, a unique constraint on the pair, and the job only ever picks up rows that haven’t reached a terminal state.
CREATE TABLE import_welcome (
import_id TEXT NOT NULL,
email TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
message_id TEXT,
last_error TEXT,
attempts INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (import_id, email)
);
CREATE INDEX ON import_welcome (import_id, state);
States: pending, sent, suppressed, failed. A crash mid-run leaves rows in pending, so restarting is safe and cheap. Without this, the honest answer to “did user 4,201 get the mail?” is a shrug.
Drop the addresses you already know are dead
Import files are recycled. They carry addresses that bounced for somebody else, and mailing them is how a clean domain earns a complaint rate. The account suppression list is queryable per address:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/email/suppression/check/test@example.com" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": { "email": "test@example.com", "suppressed": false }
}
For a large import, pull the whole list once with GET /v1/email/suppression/list and filter in memory instead of making 8,000 round trips. The API also suppresses on its own side, so a suppressed address in a batch comes back marked rather than delivered — the check is about not wasting the attempt and not skewing your own metrics.
One HTTP request per chunk
POST /v1/email/batch/send takes a messages array and answers with a per-index result. That per-index result is the whole reason to use it: partial failure is normal at this size and you need to know which rows failed.
curl -sS -X POST "https://api.infrai.cc/v1/email/batch/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"to":"ada@example.com","subject":"Your Acme account is ready","html":"<p>Hi Ada, your workspace is live.</p>"},
{"to":"grace@example.com","subject":"Your Acme account is ready","html":"<p>Hi Grace, your workspace is live.</p>"}
]
}'
{
"ok": true,
"data": {
"batch_id": "batch_4d174b4077974206aea68a48",
"results": [
{ "index": 0, "to": "ada@example.com", "status": "sent", "message_id": "msg_2ZhTtleGakhMuXd68qzTrugF", "error": null },
{ "index": 1, "to": "grace@example.com", "status": "failed", "message_id": null, "error": "VENDOR_DOWN" }
]
}
}
The batch body sits outside the documented send-and-track flow, so treat the field names as current behaviour rather than a frozen contract and check the email API reference before you wrap it in a client library. Chunk size has a ceiling too — oversized arrays come back as EMAIL_BATCH_TOO_LARGE, and 100 per request is a size that stays well under it while keeping the request count sane. Resend’s batch documentation lands on a similar number, which is a decent sanity check that it’s a vendor-side norm rather than an Infrai quirk.
The runner
Node 22, pg, no queue library. Chunks of 100, one request in flight at a time, a deliberate gap between chunks, and backoff that respects Retry-After:
// import-welcome-runner.mjs
import pg from "pg";
const key = process.env.INFRAI_API_KEY;
const importId = process.argv[2];
if (!key) throw new Error("INFRAI_API_KEY missing");
if (!importId) throw new Error("usage: node import-welcome-runner.mjs <import_id>");
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const CHUNK = 100;
const GAP_MS = 2000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const message = (email) => ({
to: email,
subject: "Your Acme account is ready",
html: `<p>We migrated your account. <a href="https://app.acme.dev/claim?e=${encodeURIComponent(email)}">Set a password</a> to get in.</p>`,
});
async function postChunk(emails, attempt = 1) {
const res = await fetch("https://api.infrai.cc/v1/email/batch/send", {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify({ messages: emails.map(message) }),
});
if (res.status === 429 || res.status >= 500) {
if (attempt > 5) throw new Error(`gave up after ${attempt} attempts (HTTP ${res.status})`);
const retryAfter = Number(res.headers.get("retry-after"));
const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 1000;
console.warn(`HTTP ${res.status}; waiting ${wait}ms then retrying chunk`);
await sleep(wait);
return postChunk(emails, attempt + 1);
}
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok !== true) throw new Error(json?.error?.code ?? `HTTP ${res.status}`);
return json.data.results;
}
async function run() {
for (;;) {
const { rows } = await pool.query(
`SELECT email FROM import_welcome
WHERE import_id = $1 AND state = 'pending' AND attempts < 3
ORDER BY email LIMIT $2`,
[importId, CHUNK],
);
if (!rows.length) break;
const emails = rows.map((r) => r.email);
let results;
try {
results = await postChunk(emails);
} catch (err) {
await pool.query(
`UPDATE import_welcome SET attempts = attempts + 1, last_error = $3, updated_at = now()
WHERE import_id = $1 AND email = ANY($2)`,
[importId, emails, String(err.message).slice(0, 200)],
);
console.error("chunk failed, rows left pending:", err.message);
await sleep(GAP_MS);
continue;
}
for (const r of results) {
const state = r.status === "sent" ? "sent" : r.error === "SUPPRESSED" ? "suppressed" : "pending";
await pool.query(
`UPDATE import_welcome
SET state = $3, message_id = $4, last_error = $5,
attempts = attempts + 1, updated_at = now()
WHERE import_id = $1 AND email = $2`,
[importId, r.to, state, r.message_id, r.error],
);
}
console.log(`chunk done: ${results.filter((r) => r.status === "sent").length}/${results.length} sent`);
await sleep(GAP_MS);
}
await pool.end();
}
await run();
100 messages every 2 seconds is 50 per second, or 180,000 an hour — deliberately slower than the API would let you go. Pacing exists for the receiving side, not for the sender.
Sending into a cap you didn’t set
A verified sending domain carries a daily ceiling that grows with history. GET /v1/email/domain/get/{domain} reports current_daily_cap and used_today under reputation, and a bulk job should read it before it starts rather than discovering the limit as a wall of rejections. Worth flagging for anyone migrating a list onto a brand-new domain: spreading the import over several days is the difference between a warmed sender and a filtered one. On a standard account there’s no custom domain at all — POST /v1/email/domain/verify answers HTTP 402 PRO_REQUIRED — so imports go out from the shared sender until you upgrade.
What a big import costs
Per email, $0.000115, verified 2026-07-26 and approximate because the underlying vendor mix can change. An 8,000-address import is therefore under a dollar, and the $2 credit on a new account covers about 17,000 messages. Every free step in the flow — suppression checks, message lookups, event history — stays free no matter how large the run. Rates trend downward here and promotions run, so pull the current number:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c 'import json,sys
for c in json.load(sys.stdin)["capabilities"]:
if c["id"] == "email.batch.send":
print(c["billing"]["price_usd"], "per", c["billing"]["unit"])'
Batch endpoint, loop, or a real queue
| Approach | Throughput control | Failure granularity | When it fits |
|---|---|---|---|
POST /v1/email/batch/send in chunks | Your sleep between chunks | Per index, in one response | Imports run from a script or a cron job |
POST /v1/email/send in a loop | Per message | Per message | A few hundred rows, or per-recipient templates |
| BullMQ or a managed queue in front | Worker concurrency and rate limit | Per job, with a dead-letter path | Continuous drip, or sends that must survive a deploy |
For a one-off import the chunked script is enough, and a queue is complexity you’d carry forever for a job that runs once. If the same import pattern repeats weekly, put a queue in front — Infrai has one on the same key, which is the practical form of the argument for consolidating: the runner, the queue and the error capture aren’t three vendors.
Limitations
There’s no scheduled or drip send here: the API sends when you call it, so “welcome sequence day 3” is your cron entry, not a provider feature. Delivery status is polled rather than pushed. And no provider dedupes an import for you — two rows with the same address in one file means two emails unless your ledger catches it.
If your import is genuinely a marketing list rather than transactional onboarding, Brevo’s batch API ships with list management and unsubscribe handling that this surface doesn’t support, and Amazon SES stays the cheapest per message once you’re willing to operate the reputation side yourself. Pick those on their merits.