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

reasonCauseWhat the user row should doReversible?
hard_bounceMailbox doesn’t exist (5.x.x)Mark email invalid, prompt for a new one at next loginOnly after the user changes address
soft_bounce_5xRepeated temporary failures (4.x.x)Pause non-essential mail, keep transactionalYes, once it clears
complainedUser pressed “report spam”Stop all marketing permanently; keep security mail under reviewNo, treat as final
unsubscribedUser used your unsubscribe linkClear the marketing consent flagYes, on explicit re-opt-in
invalidAddress failed validationFlag for correction at signupYes, on correction
manualSomeone on your team added itNote who and whyYes
user_requestSupport removed them by requestHonour it across every channel, not just emailYes, 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.

References

Browse more email developer guides