SendGrid vs Resend vs Postmark, and the aggregator option
A Node-first comparison of three transactional email APIs on template storage, domain verification and delivery evidence, 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
| SendGrid | Resend | Postmark | Infrai | |
|---|---|---|---|---|
| Template body lives | Dashboard, versioned dynamic templates | Your repo, React Email components | Hosted templates with layouts | Stored via API, flat substitution |
| Templating power | Handlebars, conditionals and loops | Full JSX | Mustachio, conditionals | {{var}} only |
| Send before owning DNS | No | Test domain, development only | Sender signature first | Yes, shared send.infrai.cc sender |
| Domain verification | Included | Included | Included | Pro plan; 402 on a standard key |
| Delivery status | Webhooks or Activity API | Webhooks | Webhooks plus message search | Polled event feed, no webhooks |
| Marketing / broadcasts | Yes, Marketing Campaigns | Broadcasts and audiences | Separate product | Not implemented, 501 |
| Other services on the key | Twilio SMS and voice | None | None | AI, 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 loops, 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 email, Resend with 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.
SendGrid, Resend and Postmark 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 is a straight cost the three 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. Two honest notes on that endpoint: it ignores a to= query filter (we passed one and got unfiltered results back), and its pagination isn’t usable for a large mailbox. Treat it as a recent-activity tail, and use the per-message event feed when you care about a 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.
What aggregating costs you
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 with Brevo or Loops. The custom sender domain is a paid tier. And the template renderer is the weakest of the four.
What you get back is that the same key reaches AI inference, object storage, queues, cron and SMS, on one bill, with per-tenant cost attribution as a query rather than a spreadsheet reconciliation across four vendors.
Price shape
Email sends bill per message at $0.000115, read on 2026-07-26; template, domain, suppression and event calls are free and rate-limited. A new account’s $2 of credit covers roughly 17,391 sends. Check the live figure instead of trusting a table that ages:
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.get('billing') or {}).get('price_usd')) for c in d['capabilities'] if c['id']=='email.send'])"
Per-message rates across this market have been falling for years and campaigns run on top of them, so the number you read today is likely lower than the one printed here. Which is the point: don’t pick a provider on a rate that both sides will change. Pick on the seam — how many accounts, keys and invoices the integration leaves behind.