SendGrid vs Resend vs Postmark, and the aggregator option

A Node-first comparison of three transactional email APIs on template storage, domain verification, delivery evidence and billing shape, plus where an aggregator fits.

All three will send a welcome email in about ten lines of Node, so the quickstart tells you nothing. What separates them six months in: where the template body lives, how domain verification is sequenced against your launch date, what you do about delivery status when you don’t want to run a webhook endpoint, and whether the credential does anything besides email. Infrai sits in this comparison as a fourth shape — an aggregated API where email is one namespace among many.

Being direct about it: if transactional email is the entire job and it always will be, one of the three specialists probably wins on depth. This page is about the axes where that’s not obvious.

What actually differs

SendGridResendPostmarkInfrai
Template body livesDashboard, versioned dynamic templatesYour repo, React Email componentsHosted templates with layoutsStored via API, flat substitution
Templating powerHandlebars, conditionals and iterationFull JSXMustachio, conditionals{{var}} only
Send before owning DNSNoTest domain, development onlySender signature firstYes, shared send.infrai.cc sender
Domain verificationIncludedIncludedIncludedPro tier; 402 on a standard key
Delivery statusWebhooks or Activity APIWebhooksWebhooks plus message searchPolled event feed, no webhooks
Marketing / broadcastsYes, Marketing CampaignsBroadcasts and audiencesSeparate productNot implemented, 501
Other services on the keySMS and voice from the parent companyNoneNoneAI, storage, queues, SMS, cron, auth

Every row above is a real difference someone will hit. Only two of them usually decide the choice.

Templates: three mental models, not three syntaxes

SendGrid’s dynamic templates put the body in a hosted editor with versions and a test-data pane, which non-engineers can genuinely use. Resend pushes the opposite way — the email is a React component, it lives beside your pages, and it’s reviewed like any other code. Postmark sits in the middle with hosted templates plus layouts, which is the closest thing to a designed system of the three.

Infrai’s model is the plainest of the four and the most limited. You store an HTML string, you get one-for-one variable substitution, and that’s it.

curl -sS -X POST https://api.infrai.cc/v1/email/template/create \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?set this to your_infrai_api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "welcome-2026",
    "subject": "Welcome to {{product}}, {{first_name}}",
    "html": "<p>Hi {{first_name}},</p><p>Your {{product}} workspace <strong>{{workspace}}</strong> is ready. First step: <a href=\"{{next_url}}\">connect a data source</a>.</p>",
    "body_text": "Hi {{first_name}}, your {{product}} workspace {{workspace}} is ready. Start here: {{next_url}}",
    "variables": ["first_name", "product", "workspace", "next_url"],
    "default_vars": {"product": "Ledgerly"},
    "tags": ["onboarding"]
  }'

default_vars earns its place. Anything you don’t pass at send time falls back to the stored default, so the product name survives a caller that forgets it:

curl -sS -X POST \
  https://api.infrai.cc/v1/email/template/preview/tmpl_0cZZSWki9BwVitO3IpTUGpvx \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?set this to your_infrai_api_key}" \
  -H "Content-Type: application/json" \
  -d '{"vars": {"first_name": "Dana", "workspace": "Acme HQ", "next_url": "https://app.example.net/connect"}}'
{
  "ok": true,
  "data": {
    "rendered_subject": "Welcome to Ledgerly, Dana",
    "rendered_html": "<p>Hi Dana,</p><p>Your Ledgerly workspace <strong>Acme HQ</strong> is ready. First step: <a href=\"https://app.example.net/connect\">connect a data source</a>.</p>",
    "missing_vars": [],
    "rendered_text": "Hi Dana, your Ledgerly workspace Acme HQ is ready. Start here: https://app.example.net/connect"
  }
}

No conditionals, no iteration, no partials. A template that needs “show the invoice table only for paying accounts” has to be split into two stored templates or pre-rendered by your own code — and if that pattern is everywhere in your mail, a component-rendered library like React Email is the better tool and you should use it.

Domain verification: same DNS records, different sequencing

All four want the same four DNS entries: an SPF TXT, a DKIM TXT under a selector, a tracking CNAME, and a DMARC policy record. The ergonomics differ in when you’re allowed to send.

The three specialists gate real sending behind an identity you control. Infrai inverts that: the first send works with no DNS at all, because the platform substitutes its own sender and returns the address it used in from_used. That’s genuinely useful for a demo or a coding agent scaffolding an app — and it’s a trap if you leave it there, since the reputation you’re borrowing isn’t yours and the From: line is wrong for a real product.

The catch is that moving off the shared sender is a paid step. On a standard key the domain-verify call answers 402 PRO_REQUIRED, which discovery declares up front as minimum_tier: "pro" on that route — so you can check it before you build, but it’s still a cost the specialists don’t impose.

Delivery evidence without running a webhook

This is where the aggregator’s design pays off for small teams. There are no email webhooks to register; you read a feed.

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

Each item carries message_id, state, to, vendor and created_at. Treat it as a recent-activity tail rather than a queryable archive — its paging isn’t designed for a mailbox with a million rows — and use the per-message event feed when you care about one specific send. Webhook-driven providers give you push instead, which is better at volume and worse on your first afternoon.

A bake-off you can run before you commit

Send the same welcome mail through each candidate, then measure what you actually care about: end-to-end latency and how long the status takes to settle.

// bakeoff.mjs — Node 22 ESM. Times one send and waits for the state to settle.
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("export INFRAI_API_KEY=your_infrai_api_key");

const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function json(res) {
  const payload = await res.json();
  if (!res.ok || payload.ok === false) throw new Error(payload?.error?.code ?? `http_${res.status}`);
  return payload.data;
}

const started = Date.now();
const sent = await json(await fetch(`${API}/v1/email/send`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    to: "dana@example.net",
    template_id: "tmpl_0cZZSWki9BwVitO3IpTUGpvx",
    template_vars: { first_name: "Dana", workspace: "Acme HQ", next_url: "https://app.example.net/connect" },
  }),
}));
console.log(`accepted in ${Date.now() - started}ms as ${sent.message_id} from ${sent.from_used}`);

for (let i = 0; i < 10; i++) {
  await sleep(2000);
  const status = await json(await fetch(`${API}/v1/email/get/${sent.message_id}`, { headers }));
  console.log(`t+${(i + 1) * 2}s state=${status.state} vendor=${status.vendor}`);
  if (status.state === "delivered" || status.state === "bounced") break;
}

Run the same script against each provider’s equivalent routes and you’ll have numbers instead of opinions. In our own runs the accept step came back in roughly a second and the state moved to sent immediately, with the vendor’s own id visible in the event feed.

Billing shape, which outlives any rate

A rate comparison is worthless six months out — every provider on this page has repriced, and so have we. What survives is the shape of the meter:

What is meteredUnitWhat counts as one sendWhat the entry tier covers
SendGridEmails above a plan allowanceMonthly plan fee plus per-email overageOne recipient; each address in a personalization bills separatelyA small daily allowance
ResendEmails against a plan’s included volumePer email inside a monthly tierOne email; a batch entry counts onceMonthly allowance with a daily cap
PostmarkEmails against a volume-sized planPer email inside a monthly tierOne email to one recipientTest sends to verified addresses only
InfraiThe send call only; template, domain, suppression and event calls are freeper_email, no plan, no floor, no seatOne accepted POST /v1/email/send$2 of trial credit on signup

Read the middle rows off each vendor’s own pricing page before you decide — allowances move. The one absolute number worth printing here is ours, and it comes with an expiry date attached: POST /v1/email/send bills $0.00046 per email, read on 2026-07-27 and published as approximate because the vendor mix underneath moves. Don’t budget from that sentence. The same billing block in discovery also publishes new_account_trial_uses, so the API tells you what the trial credit is worth today instead of leaving you to multiply:

curl -sS https://api.infrai.cc/v1/discovery \
  -H "Authorization: Bearer ${INFRAI_API_KEY:?set this to your_infrai_api_key}" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print([(c['id'],c['billing']) for c in d['capabilities'] if c['id']=='email.send'])"

What aggregating costs you, and what it buys

Three things, stated plainly. Marketing mail isn’t available — message_class: "marketing" returns 501 CAPABILITY_NOT_IMPLEMENTED, and there are no audiences, broadcasts or subscription-management surfaces, so campaign work belongs somewhere else entirely. The custom sender domain is a paid tier. And the template renderer is the weakest of the four.

Buy Postmark if transactional delivery is the whole job for the foreseeable future and you want a vendor whose support team will argue with a receiving provider on your behalf. Buy SendGrid if you need campaign tooling and transactional sending under one roof, or a dedicated IP with a human attached to it.

The counter-argument is adjacency, not price. The next step after the send is already on the same key: POST /v1/queue/publish for the retry, POST /v1/cron/create for the follow-up, POST /v1/errors/capture for the delivery that threw, GET /v1/account/usage for per-tenant cost attribution. No second account, no second vendor, no second invoice to reconcile — and that’s the one thing a specialist can’t match by discounting.

References

Browse more email developer guides