Bounces and complaints: mapping suppression reasons to a user row
Seven suppression reasons, what each one should do to your users table, and a Node 22 reconciliation job that keeps both sides of the truth in sync.
Most bounce-handling advice stops at “remove the address”. That’s the easy half. The harder half is deciding what a bounce means to the account behind the address — whether the user should be asked to fix their email, locked out of password reset, or quietly left alone — and Infrai splits that decision cleanly: the platform keeps an enforced suppression list with a typed reason, and you keep the user row it maps onto.
Both sides are readable over the same key, which is the point of this piece. GET /v1/email/suppression/list is the provider’s state, your users table is yours, and a nightly reconciliation between them is about forty lines of Node 22.
The seven reasons, and what each should change
reason | Cause | What the user row should do | Reversible? |
|---|---|---|---|
hard_bounce | Mailbox doesn’t exist (5.x.x) | Mark email invalid, prompt for a new one at next login | Only after the user changes address |
soft_bounce_5x | Repeated temporary failures (4.x.x) | Pause non-essential mail, keep transactional | Yes, once it clears |
complained | User pressed “report spam” | Stop all marketing permanently; keep security mail under review | No, treat as final |
unsubscribed | User used your unsubscribe link | Clear the marketing consent flag | Yes, on explicit re-opt-in |
invalid | Address failed validation | Flag for correction at signup | Yes, on correction |
manual | Someone on your team added it | Note who and why | Yes |
user_request | Support removed them by request | Honour it across every channel, not just email | Yes, on request |
RFC 3463 is the vocabulary underneath the first two rows: a 5.x.x enhanced status is permanent and a 4.x.x is transient. The distinction matters because a hard bounce on a password reset is a support ticket waiting to happen, while a soft bounce is usually a full mailbox that fixes itself by Tuesday.
Treating both the same way is the most common mistake.
Reading the list
The list is queryable, filterable by reason, and cursor-paged.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/email/suppression/list?limit=100" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "email": "noreply@example.com", "reason": "manual", "added_at": "2026-07-25T16:02:13.112374Z", "scope": "account", "attempt_count_blocked": 0 },
{ "email": "unsub-probe@example.com", "reason": "unsubscribed", "added_at": "2026-06-29T09:54:38.768897Z", "scope": "account", "attempt_count_blocked": 3 }
],
"count": 2,
"next_cursor": null
}
}
attempt_count_blocked is the field worth watching. A suppressed address that keeps accumulating blocked attempts means some code path in your app is still trying to mail it — usually a digest job that reads from a stale materialised view. Nobody notices, because nothing errors: the send returns 200 with the address sitting in suppressed_recipients and zero delivered.
The reconciliation job
This runs on a schedule and pulls forward from a stored watermark, so a restart doesn’t re-import the whole list. It writes into Postgres via pg; swap the two SQL statements for whatever your ORM wants.
import { readFile, writeFile } from "node:fs/promises";
import pg from "pg";
import process from "node:process";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const WATERMARK = process.env.SUPPRESSION_WATERMARK_FILE ?? "./.suppression-watermark";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const ACTION = {
hard_bounce: "email_invalid",
invalid: "email_invalid",
soft_bounce_5x: "pause_non_essential",
complained: "marketing_blocked",
unsubscribed: "marketing_opt_out",
manual: "review",
user_request: "review",
};
async function page(cursor) {
const query = new URLSearchParams({ limit: "100" });
if (cursor) query.set("cursor", cursor);
const res = await fetch(`${API}/v1/email/suppression/list?${query}`, {
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
});
const payload = await res.json().catch(() => ({}));
if (!res.ok || payload.ok === false) {
const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
throw new Error(`suppression.list -> ${err.code}: ${err.message}`);
}
return payload.data;
}
const since = await readFile(WATERMARK, "utf8").then((s) => s.trim()).catch(() => "");
const db = new pg.Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
let cursor = null;
let newest = since;
let applied = 0;
for (let i = 0; i < 100; i++) {
const data = await page(cursor);
for (const entry of data.items ?? []) {
if (since && entry.added_at <= since) continue;
const action = ACTION[entry.reason] ?? "review";
await db.query(
"UPDATE users SET email_state = $1, email_state_reason = $2, email_state_at = $3 WHERE lower(email) = lower($4)",
[action, entry.reason, entry.added_at, entry.email],
);
if (entry.added_at > newest) newest = entry.added_at;
applied++;
}
cursor = data.next_cursor ?? null;
if (!cursor) break;
}
await db.end();
if (newest && newest !== since) await writeFile(WATERMARK, newest, "utf8");
console.log(`applied ${applied} suppression changes; watermark=${newest || "start"}`);
Make the update idempotent — it is, above, because it’s a straight assignment keyed on the address rather than an increment. A crash between the last UPDATE and the watermark write replays a handful of rows on the next run and changes nothing, which is the behaviour you want at 04:00 when nobody’s watching.
Attributing a bounce to the message that caused it
Sometimes you need the other direction: not “which addresses are dead” but “what happened to this specific message”. The event timeline answers that, and it’s addressed by message_id.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_jiAQ671ekGVqfGXj1LL27Gac&type=bounced" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"type": "bounced",
"at": "2026-07-26T00:31:44.108Z",
"recipient": "typo@exmaple.com",
"message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac",
"meta": { "bounce_type": "hard", "reason": "550 5.1.1 recipient rejected" }
}
],
"next_cursor": null,
"count": 1
}
}
The catch is that message_id is mandatory on this route — there’s no account-wide event stream you can tail. To sweep recent activity you list messages first with GET /v1/email/list, then fan out per message, which is fine for hundreds of sends a day and clumsy at millions. For the “which addresses are dead” question, the suppression list is both cheaper and authoritative, so prefer it.
Writing back: your unsubscribe page
Suppression isn’t read-only. When a user clicks unsubscribe on your own page, push it to the same list so every future send — from any service on your account — respects it.
curl -sS -X POST "https://api.infrai.cc/v1/email/suppression/add" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"email":"unsub-probe@example.com","reason":"unsubscribed"}'
Removal exists too, at DELETE /v1/email/suppression/delete/{email}, and it should be rare. An address that complained and got un-suppressed because a support agent was being helpful is the single fastest way to push a domain’s complaint rate toward the 0.3% line that mailbox providers start throttling at.
What the loop costs
Every read and write in this article is free and rate-limited — the suppression list, the event feed, the message record, adding and deleting entries. Only the send is metered, at $0.000115 per recipient, verified 2026-07-26, with a new account’s $2 credit covering roughly 17,391 messages. That asymmetry is deliberate and it’s the durable point: hygiene work shouldn’t have a meter on it. Rates drift downward as vendor discounts land, so read the live figure yourself:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"
Where you’d want something else
Postmark’s bounce webhooks carry the full remote SMTP transcript and arrive in seconds; if your product surfaces a diagnostic string to end users, that detail is worth the receiver you have to build and secure. Amazon SES wired to SNS gives you the same push model at a lower unit price, provided you’re happy owning the subscription, the retry policy and the dead-letter queue.
The limitations here are worth naming. No webhooks, so everything is pulled. No global event stream, so per-message queries only. And a send from an unverified domain returns EMAIL_INVALID_STATE rather than silently using a fallback in some configurations, which surprises people once.
What tips it for a small team is that the cron entry running this job, the database it writes to, and the alert that fires when bounce_rate_30d crosses your threshold are all reachable from the one credential you already have.