Polling transactional email delivery status from Node 22, with no webhooks

Infrai has no push webhooks for email yet, so you poll. A cron worker, a sane backoff schedule, and the difference between the state read and the event timeline.

There are no push webhooks for email on Infrai today. You poll — and for transactional mail that’s less of a compromise than it sounds, because delivery outcomes usually arrive over 30 seconds to a few minutes rather than in real time, and a worker that checks a few hundred message ids on a schedule costs nothing (every read route in the email surface is free, and reads don’t touch the trial credit).

What you do need is a schedule that matches the physics and a place to stop. A loop that hammers a message id forever because it never reached a terminal state is the classic way to turn a free API into a busy one.

Two routes answer two different questions

GET /v1/email/get/{id} gives you the message’s current state in one small object. GET /v1/email/event/list gives you the ordered timeline behind that state, per recipient. Most workers want the first; anything that reports opens, clicks or bounce reasons wants the second.

Start by keeping the handle. Every send returns a message_id, and without it there’s nothing to poll:

curl -s -X POST https://api.infrai.cc/v1/email/send \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "dana@example.com",
    "from": "receipts@example.com",
    "subject": "Your invoice is ready",
    "html": "<p>Invoice 4021 is attached to your account.</p>"
  }'
{
  "ok": true,
  "data": {
    "message_id": "msg_4TbW8vQpS1nJ6kZrX2yLdCmE",
    "from_used": "receipts@example.com",
    "mode": "live",
    "accepted_recipients": ["dana@example.com"],
    "suppressed_recipients": []
  }
}

Write that id into the same database transaction that created the invoice. If the id only exists in a log line, your reconciliation job has nothing to join on.

The state read

curl -s https://api.infrai.cc/v1/email/get/msg_4TbW8vQpS1nJ6kZrX2yLdCmE \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "message_id": "msg_4TbW8vQpS1nJ6kZrX2yLdCmE",
    "state": "sent",
    "channel": "email",
    "to": "dana@example.com",
    "vendor": "resend",
    "created_at": 1785025802.378
  }
}

The response is deliberately small — a state, a recipient, the vendor that handled it. There’s no per-recipient breakdown here, so a message sent to three addresses needs the event timeline to tell you which of the three bounced. An unknown id answers EMAIL_NOT_FOUND with HTTP 404, which is the case your worker should treat as terminal rather than retryable.

The timeline

message_id is a required query parameter on the event route. Call it bare and you get INVALID_ARGUMENT back rather than a firehose of everything the account ever sent — a deliberate limitation, and one that shapes how you write the worker, since you fan out per message instead of tailing a global stream.

curl -s "https://api.infrai.cc/v1/email/event/list?message_id=msg_4TbW8vQpS1nJ6kZrX2yLdCmE" \
  -H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
  "ok": true,
  "data": {
    "items": [
      { "type": "queued", "at": "2026-07-26T00:30:02.284184Z", "recipient": "dana@example.com", "message_id": "msg_4TbW8vQpS1nJ6kZrX2yLdCmE", "meta": { "vendor": "resend" } },
      { "type": "sent", "at": "2026-07-26T00:30:02.301347Z", "recipient": "dana@example.com", "message_id": "msg_4TbW8vQpS1nJ6kZrX2yLdCmE", "meta": { "vendor_message_id": "7df213d7-fa5d-4ceb-88eb-5ce0198103a6" } }
    ],
    "next_cursor": null,
    "count": 2
  }
}

next_cursor is null when the page is complete; a non-null value means pass it back to fetch the rest. For a single transactional message you’ll almost never paginate, but a campaign-sized recipient list will.

Choose a cadence, then stop

Delivery signals cluster early and then thin out. A schedule that reflects that beats a fixed interval on both freshness and request count.

Age of messagePoll everyRationale
0–2 minutes15 secondsMost sent and hard-bounce signals land here
2–30 minutes2 minutesGreylisting and retry queues resolve in this band
30 minutes – 6 hours15 minutesSoft bounces and deferrals
6–24 hours1 hourLong-tail deferrals
Past 24 hoursstopTreat as terminal-unknown and alert

The last row is the one people forget. Without a hard stop you accumulate zombie rows and your worker’s request count grows with the age of your product rather than with your send volume.

A cron worker in Node 22

The shape below assumes an outbox table with message_id and state columns; swap the two store functions for your own queries. It processes pending rows with bounded concurrency, treats a 404 as terminal, and never touches a message that already reached a final state.

// poll-delivery.mjs — Node 22, no dependencies. Run from cron every minute.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const TERMINAL = new Set(["delivered", "bounced", "complained", "failed", "terminal_unknown"]);
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
const CONCURRENCY = 8;
const headers = { authorization: `Bearer ${KEY}` };

// Replace these two with real queries against your outbox table.
const store = new Map();
async function loadPending() {
  return [...store.values()].filter((row) => !TERMINAL.has(row.state));
}
async function saveState(messageId, state) {
  store.set(messageId, { ...store.get(messageId), messageId, state });
}

async function readState(messageId) {
  const res = await fetch(`${API}/v1/email/get/${encodeURIComponent(messageId)}`, { headers });
  if (res.status === 404) return "terminal_unknown";
  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    throw new Error(payload.error?.code ?? `HTTP_${res.status}`);
  }
  return payload.data.state;
}

async function runPool(rows, worker, size) {
  const queue = [...rows];
  const runners = Array.from({ length: Math.min(size, queue.length) }, async () => {
    while (queue.length) {
      const row = queue.shift();
      try {
        await worker(row);
      } catch (err) {
        console.error(`poll failed for ${row.messageId}: ${err.message}`);
      }
    }
  });
  await Promise.all(runners);
}

const pending = await loadPending();
console.log(`polling ${pending.length} messages`);
await runPool(pending, async (row) => {
  if (Date.now() - row.sentAt > MAX_AGE_MS) {
    await saveState(row.messageId, "terminal_unknown");
    return;
  }
  const state = await readState(row.messageId);
  if (state !== row.state) {
    console.log(`${row.messageId}: ${row.state} -> ${state}`);
    await saveState(row.messageId, state);
  }
}, CONCURRENCY);

Bounded concurrency is the part worth keeping when you adapt this. Firing one request per pending row with Promise.all works fine at 50 messages and falls over at 5,000 — the pool keeps eight in flight regardless of backlog size, which is both politer to the API and easier to reason about. Node 22 ships fetch and top-level await natively, so there’s nothing to install; a 2026 LTS runtime runs this file as written.

Wire it to the system scheduler with one line:

* * * * * cd /srv/app && INFRAI_API_KEY=your_infrai_api_key /usr/bin/node poll-delivery.mjs >> /var/log/poll-delivery.log 2>&1

If your app already runs a job service, use that instead of crontab — POST /v1/cron/create will register the same command on the same account, which keeps the schedule visible next to everything else rather than hidden on one box.

When polling is the wrong answer

If you need a delivery event in your system within a second of it happening — a live agent console, a fraud rule that reacts to a complaint — polling won’t help, and a webhook-first provider is the right tool. Resend, Postmark and Mailgun all push events over HTTP and have done for years, and if that’s your requirement you should stick with one of them for this workload.

The trade-off runs the other way for most transactional mail. Polling has no public endpoint to secure, no signature verification, no retry-storm behaviour when your receiver is down, and no missed events during a deploy — the state is still there when you next ask. Add the fact that the read routes are free and that the same key already covers the queue, the cron job and the error capture around this worker, and the pull model earns its place for receipts, invoices and reset mail.

References

Browse more email developer guides