Simplest transactional email for an EU/US startup: count the moving parts

Judge providers by how much machinery you end up running: a moving-parts scorecard, what each bounce type should do to a user row, and a one-file polling layer.

“Simplest” is countable, so count it: accounts to create, credentials to rotate, DNS records to publish, long-running processes to keep alive, dashboards to check, invoices to reconcile. Every comparison table ranks providers on features, which is the wrong axis for a four-person team — you want the option that leaves the fewest things running. By that measure a polled HTTP API wins, and Infrai’s email routes are built around polling because there’s nothing for you to host.

The trade you’re making is freshness for machinery. A webhook tells you about a bounce in two seconds; a poll tells you in five minutes and needs no public endpoint, no signature verification and no on-call rotation for a receiver that fell over.

The scorecard

What you end up runningESP with webhooksAmazon SES + SNSPolled API
Vendor accounts11 (plus IAM)1
Credentials to rotateAPI key + webhook secretIAM keys, topic policiesAPI key
DNS records3–43–43–4
Always-on processesA public receiverA Lambda or consumerNone — a scheduled job
Failure mode you ownReceiver downtime loses eventsQueue backlog, IAM driftStaleness up to your poll interval
Time to first sendMinutesHours, plus sandbox exitMinutes

Three of those rows are the same everywhere. The fourth is the one that decides your weekend.

Warm-up, briefly

A new sending domain starts with a low daily cap that climbs as clean volume accumulates — 500 a day is the usual starting point, rising toward tens of thousands. Nothing you buy shortens that, and a service promising otherwise is selling artificial engagement rather than reputation. Verify the domain the week before launch, not the morning of.

Publish the records the API hands you, then watch used_today against current_daily_cap in the same job that does everything else.

What each bounce type should do to your user row

This is where most small teams stop short. They wire up delivery tracking, look at it twice, and never connect it to the user record — so the same dead address gets emailed every week for a year.

SignalMeaningWhat to do to the user rowWhat the user sees
Hard bounceAddress doesn’t existemail_status = blocked, stop all sendingAn in-app banner asking for a new address
Soft bounce ×1Mailbox full, temporaryNothing yet; count itNothing
Soft bounce ×3 in 7 daysPersistent problememail_status = degraded, drop to essential mail onlyNothing
ComplaintMarked as spamemail_status = blocked, and audit what you sentNothing — never re-permission by email
suppressed_recipients on sendAlready blocked upstreamSync your row to matchThe in-app banner again

The last row matters more than it looks. Suppression lives on the platform side too, so a send you thought went out can be silently dropped — reading that array is how your database stays in agreement with reality.

The whole email layer, in one file

import process from "node:process";

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 FROM = "notifications@mail.example.com";
const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

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

/** Send one transactional message. Returns null when the address is blocked. */
export async function send({ to, subject, html }) {
  const data = await call("/v1/email/send", {
    method: "POST",
    body: JSON.stringify({ to, from: FROM, subject, html }),
  });
  return data.suppressed_recipients.includes(to) ? null : data.message_id;
}

/** Scheduled sweep: reconcile recent sends against your user table. */
export async function sweep(users) {
  const recent = await call("/v1/email/list?limit=100");
  const verdicts = [];

  for (const message of recent.records ?? recent.items ?? []) {
    const events = await call(`/v1/email/event/list?message_id=${message.message_id}`);
    for (const event of events.records ?? []) {
      if (event.type === "bounced") {
        verdicts.push({ email: event.recipient, status: "blocked", cause: "bounce" });
      } else if (event.type === "complained") {
        verdicts.push({ email: event.recipient, status: "blocked", cause: "complaint" });
      }
    }
  }

  for (const verdict of verdicts) {
    await users.setEmailStatus(verdict.email, verdict.status, verdict.cause);
  }
  return verdicts.length;
}

const changed = await sweep({ setEmailStatus: async () => {} });
console.log(`sweep updated ${changed} user rows`);

Roughly forty lines, no dependencies, no server. Put sweep on a five-minute schedule and that’s the entire operational surface of your email system — which is the actual argument for polling, rather than any claim about it being technically superior.

Here’s what the message list it reads looks like:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/email/list?limit=25" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "records": [
      { "message_id": "msg_Vt6cRj2XkQ9pLbNw", "to": "user@example.com", "state": "delivered", "created_at": "2026-07-26T07:55:02Z" },
      { "message_id": "msg_Hs4mWn8ZyD1fKqTr", "to": "old@example.com", "state": "bounced", "created_at": "2026-07-26T07:41:19Z" }
    ],
    "count": 2,
    "next_cursor": null
  }
}

And a single send, for completeness:

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to":"user@example.com","from":"notifications@mail.example.com","subject":"Your trial ends in 3 days","html":"<p>Add a card to keep your workspace after Friday.</p>"}'

For a batch of reminders, POST /v1/email/batch/send takes many messages in one request — oversize batches come back as EMAIL_BATCH_TOO_LARGE, so chunk your list rather than posting all of it.

The block list your sweep is keeping in sync is readable directly:

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

EU or US: the four questions worth asking

Most “EU-ready” claims collapse into four practical questions, and it’s worth writing them down before you compare marketing pages.

Where is the message processed? Who are the sub-processors, by name? How long is event history retained, and can you delete a person from it? And can any of that be pinned contractually, or is it just where the servers happen to be today?

Our honest answer: the live vendor behind the send route is Resend, with Amazon SES and a China-region path wired but not yet serving, and there’s no per-request region pin. If a DPA in your sales cycle names an EU processing region, you need a provider that sells one as a product — Mailgun operates an EU region and Brevo is a European company, and either is the safer choice for that requirement. Nothing about the code above changes if you switch; that’s the point of a plain REST surface.

What it costs

Sends are $0.000115 per recipient, verified 2026-07-26. The message list, event feed, suppression list and domain reads used by the sweep are free and rate-limited rather than metered, so running it every five minutes costs nothing — 288 sweeps a day, $0 of them billable. A new account’s $2 credit covers roughly 17,391 messages. Rates drift down as vendor discounts land, so read the number rather than quoting it:

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 this is the wrong answer

If your product genuinely needs to react to a bounce within seconds — a checkout flow that must fall back to SMS, say — polling is not a good fit and you should buy webhooks. If you need a named EU processing region in a contract, see above. And custom sender domains sit behind a paid plan here, so POST /v1/email/domain/verify answers HTTP 402 PRO_REQUIRED on a standard account, which is an unwelcome surprise mid-evaluation.

If email is the only thing you’ll ever buy from anyone, a specialist like Postmark is a fine answer and we’d not argue with it. The scorecard tips the other way as soon as the second requirement lands: the reminder above needs a schedule, the failed sweep needs an error event, the exported report needs object storage, and finance wants per-tenant attribution. On one key those are four calls; on four vendors they’re four contracts.

References

Browse more email developer guides