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/event/list returns a cursor-paged timeline of delivered, bounced, opened, clicked and complained records, GET /v1/email/get/{id} collapses one message 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, SendGrid and Postmark all do push well and have done for years, and their event webhooks carry richer bounce metadata than a polled summary does. If you’re building an inbox-placement product where a complaint has to reach your system inside a second, you’d be better off with one of them. For a SaaS that 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": {
    "records": [
      { "type": "bounced", "recipient": "typo@exmaple.com", "occurred_at": "2026-07-26T08:41:19.220Z" },
      { "type": "sent", "recipient": "typo@exmaple.com", "occurred_at": "2026-07-26T08:41:03.884Z" },
      { "type": "queued", "recipient": "typo@exmaple.com", "occurred_at": "2026-07-26T08:41:02.117Z" }
    ],
    "total_count": 3,
    "next_cursor": null
  }
}

Three fields per record, and that’s deliberate: type, recipient, occurred_at. For a rollup across recipients of the same message — how many delivered, how many bounced — the cheaper call is the single-message read, which returns state, per_recipient_state and counts in one shot.

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

The sweeper

Poll the whole account feed rather than per message. This is a Node 22 script you can run from cron every minute; it walks next_cursor until the page is short, hands each event to your own handler, and persists the cursor 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 CURSOR_FILE = process.env.EMAIL_CURSOR_FILE ?? "./.email-cursor";
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 loadCursor() {
  try { return (await readFile(CURSOR_FILE, "utf8")).trim() || null; }
  catch { return null; }
}

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

let cursor = await loadCursor();
let processed = 0;

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

  for (const record of data.records ?? []) {
    onEvent(record);
    processed++;
  }
  if (!data.next_cursor || (data.records ?? []).length < PAGE) {
    cursor = data.next_cursor ?? cursor;
    break;
  }
  cursor = data.next_cursor;
}

if (cursor) await writeFile(CURSOR_FILE, cursor, "utf8");
console.log(`processed ${processed} events, cursor=${cursor ?? "start"}`);

Two details save pain later. Make onEvent idempotent — a crash between processing an event and writing the cursor means you’ll see some records twice on the next run, and a handler that blindly increments a per-tenant counter will quietly lie about 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.000115 per recipient, verified 2026-07-26. New accounts get $2 in credit, roughly 17,391 emails. Polling the event feed 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 reading per_recipient_state from the message record and, sometimes, asking the vendor.

Sending itself runs on Resend in the western region right now, with Amazon SES and Tencent listed as pending, 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 key: the cron entry that runs the sweeper, the queue that retries a failed batch, the error tracker that catches the day DNS breaks, and per-tenant cost attribution as a single query instead of four exports. For a small team, not owning a webhook endpoint is a feature.

References

Browse more email developer guides