Bounce and complaint handling without webhooks: poll the event feed

Hard bounces, spam complaints and suppression on a polled feed: cursor paging, the reputation fields worth alerting on, and when a webhook provider still wins.

If you don’t want a public webhook endpoint, you need an email API whose event history is queryable after the fact. Infrai’s is: GET /v1/email/list walks the account’s recent messages, GET /v1/email/event/list returns one message’s timeline — queued, sent, delivered, bounced, complained — GET /v1/email/get/{id} collapses that timeline to a single state, and hard bounces and complaints land on the account suppression list automatically. Every one of those reads is free.

That last part is what makes polling practical here. A webhook receiver costs you a public route, signature verification, replay protection and an on-call story for the hours it was down; a polled feed costs you a cron entry. The trade-off is latency, and for bounce handling the latency mostly doesn’t matter — you’re not blocking a user on the answer, you’re keeping a list clean.

Push or pull, judged on operational cost

ConcernWebhook receiverPolled event feed
Public surface requiredyes, plus signature checksnone
Reaction timesecondsyour poll interval, typically 60s
Missed events while you were downvendor retries, then dropsnothing is missed; the cursor waits
Local developmenttunnel or ngrokworks against localhost as-is
Backfill after a bugreplay if the vendor kept itre-read from an earlier cursor
Orderingnot guaranteedas returned, oldest cursor forward
Cost per eventyour computefree, rate-limited reads

Polling is boring, and boring is the point.

Mailgun does push properly and has done for years; its event webhooks carry richer bounce metadata than a polled summary ever will. If you’re building an inbox-placement product where a complaint has to reach your system inside a second, you’d be better off there. For a SaaS that just wants to stop mailing addresses that bounce, pull is fine.

Read the timeline for one message

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_DgOWYJSuArAxcSI9MCzYLSJp&limit=50" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "type": "bounced", "at": "2026-07-26T08:41:19.220Z", "recipient": "typo@exmaple.com", "message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp", "meta": { "vendor_message_id": "abaf5e78-cb8e-4119-8afb-54bcce24d777" } },
      { "type": "sent", "at": "2026-07-26T08:41:03.884Z", "recipient": "typo@exmaple.com", "message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp", "meta": { "vendor_message_id": "abaf5e78-cb8e-4119-8afb-54bcce24d777" } },
      { "type": "queued", "at": "2026-07-26T08:41:02.117Z", "recipient": "typo@exmaple.com", "message_id": "msg_DgOWYJSuArAxcSI9MCzYLSJp", "meta": { "vendor": "resend" } }
    ],
    "next_cursor": null,
    "count": 3
  }
}

message_id is required here, and leaving it off returns a 400 that says so. That’s the design telling you this is a per-message read, not an account firehose — each entry carries type, at, recipient and a meta block with the vendor’s own id. When all you want is where a message ended up, the single-message read collapses the timeline to state, alongside to, vendor and created_at.

curl -sS "https://api.infrai.cc/v1/email/get/msg_DgOWYJSuArAxcSI9MCzYLSJp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The sweeper

So a sweep is two calls deep: page the message list, then read the timeline of anything that hasn’t settled yet. The listing is the cheap half and it pages the same way everything else here does.

curl -sS "https://api.infrai.cc/v1/email/list?limit=100" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Wrapped up, that’s a Node 22 script you can run from cron every minute. It walks next_cursor on the listing, hands each event to your own handler, and remembers which message ids reached a terminal state so a restart doesn’t re-process a week of history.

import { readFile, writeFile } from "node:fs/promises";
import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const SETTLED_FILE = process.env.EMAIL_SETTLED_FILE ?? "./.email-settled";
const TERMINAL = new Set(["delivered", "bounced", "complained", "failed"]);
const PAGE = 100;

if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function get(path) {
  const res = await fetch(API + path, {
    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(`GET ${path} -> ${err.code}: ${err.message}`);
  }
  return payload.data;
}

async function loadSettled() {
  try { return new Set(JSON.parse(await readFile(SETTLED_FILE, "utf8"))); }
  catch { return new Set(); }
}

function onEvent(record) {
  if (record.type === "bounced") {
    console.log(`bounce: ${record.recipient} at ${record.at}`);
  } else if (record.type === "complained") {
    console.warn(`complaint: ${record.recipient} — stop all non-essential mail to this address`);
  }
}

const settled = await loadSettled();
let cursor = null;
let processed = 0;

for (let page = 0; page < 50; page++) {
  const query = new URLSearchParams({ limit: String(PAGE) });
  if (cursor) query.set("cursor", cursor);
  const listing = await get(`/v1/email/list?${query.toString()}`);

  for (const message of listing.items ?? []) {
    if (settled.has(message.message_id)) continue;
    const timeline = await get(`/v1/email/event/list?message_id=${message.message_id}&limit=50`);
    for (const record of timeline.items ?? []) {
      onEvent(record);
      processed++;
    }
    if (TERMINAL.has(message.state)) settled.add(message.message_id);
  }

  cursor = listing.next_cursor;
  if (!cursor) break;
}

await writeFile(SETTLED_FILE, JSON.stringify([...settled]), "utf8");
console.log(`processed ${processed} event(s); ${settled.size} message(s) settled`);

Two details save pain later. Make onEvent idempotent — a crash between handling an event and persisting the settled set means you’ll see some records twice on the next run, and a handler that blindly increments a per-tenant counter will misreport your bounce rate for the rest of the month, which is worse than not measuring it at all. And cap the page loop, as above, because an unbounded while against a paginated API is how a one-minute cron job turns into a rate-limit incident at 3am.

Suppression is the state, events are the log

Bounces and complaints don’t just get reported. They get enforced: a hard bounce or a complaint puts the address on the account suppression list, and a later send to it comes back in suppressed_recipients with nothing dispatched. You don’t have to build that reconciliation yourself.

Check one address before a signup flow trusts it:

curl -sS "https://api.infrai.cc/v1/email/suppression/check/typo@exmaple.com" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "email": "typo@exmaple.com",
    "suppressed": true,
    "reason": "hard_bounce",
    "scope": "account",
    "added_at": "2026-07-26T08:41:20.004Z"
  }
}

Your own unsubscribe page writes to the same list with POST /v1/email/suppression/add, and GET /v1/email/suppression/list gives you the full set for an export. Removal exists too — DELETE /v1/email/suppression/delete/{email} — but reach for it rarely. An address that complained once and got un-suppressed because a support agent clicked something is exactly how a domain’s complaint rate creeps toward the threshold that gets it throttled.

Monitoring: one number, checked on a schedule

The reputation block on a verified domain is where deliverability monitoring actually lives.

curl -sS "https://api.infrai.cc/v1/email/domain/get/mail.example.com" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "verification": { "status": "verified" },
    "reputation": {
      "tier": "warming_up",
      "current_daily_cap": 50000,
      "used_today": 812,
      "bounce_rate_30d": 0.031,
      "throttle_risk": "medium"
    }
  }
}

A 30-day bounce rate of 3.1% is the kind of thing you want an alert on, not a dashboard for. RFC 3463 is the vocabulary underneath — a 5.x.x status is permanent and belongs on the suppression list, a 4.x.x is temporary and will be retried — and throttle_risk is the platform’s own read on whether your next batch gets slowed down.

What it costs to run this

Every route in this article is free and rate-limited except the send itself, which is $0.00046 per recipient — $0.46 per thousand — verified 2026-07-27. Polling the message list and each timeline once a minute, all day, adds nothing to the bill; that’s the structural point, not the rate. Prices here tend to move downward as vendor discounts land, so pull today’s number rather than believing this line:

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'))"

Limitations worth knowing before you commit

There’s no webhook support on this surface at all — no callback registration, no signed payloads. If your product genuinely needs push, that’s a hard stop and Mailgun’s event webhooks are the mature choice. The polled feed also gives you the event type but not the receiving server’s full diagnostic text, so root-causing an unusual bounce means taking meta.vendor_message_id off the event and, sometimes, asking the vendor with it in hand.

Sending itself runs on Resend in the western region right now, with two further vendors wired but not yet serving, so you can’t pin a specific IP pool per message.

What you get in exchange is that the follow-on work is already on the same account: POST /v1/cron/create schedules the sweeper itself, POST /v1/errors/capture keeps the run that threw halfway through a page, and GET /v1/account/usage turns per-tenant cost attribution into one query instead of four exports. No second account, no second bill. For a small team, not owning a webhook endpoint is a feature.

References

Browse more email developer guides