Resend vs Postmark developer experience, and a third option

How the two feel different once you get past hello-world: templating models, domain verification, event access, and where an aggregated API changes the call site.

Both get a Node service sending from a custom domain inside an afternoon, and both make you publish the same DKIM and return-path records first, so “easiest setup” isn’t really the axis that separates them. Resend optimises for the first hour — React Email, a clean SDK, a dashboard that reads like a developer tool. Postmark optimises for month nine, when someone asks why a password reset didn’t arrive and you need message history and a support engineer who speaks SMTP.

There’s a third shape worth knowing about, which is what Infrai does: keep the same REST call site and let the platform hold the vendor relationship. The western email region runs on Resend today, so the deliverability characteristics are the ones you’d get direct — what changes is that templates, suppression, domain state and event history come back through one credential that also covers SMS, storage, cron and error tracking.

Where the two actually diverge

AxisResendPostmarkInfrai email API
TemplatingReact Email components, compiled in your appHandlebars-style templates stored server-sideServer-side templates with named variables and a preview call
First send from own domainDNS records, then sendDNS records, then sendDNS records via POST /v1/email/domain/verify, then send
Delivery historyDashboard plus APIDashboard search, long retention, its main selling pointGET /v1/email/list and GET /v1/email/event/list
StreamsSingle sending surfaceTransactional and broadcast streams kept apartSingle surface; separate streams aren’t modelled
Switching cost laterRewrite against a new SDKRewrite against a new SDKfrom and template_id stay put; the vendor moves underneath
Other services on the same keyEmail onlyEmail onlyEmail, SMS, storage, queues, cron, auth, error tracking

The row about streams is the one people underestimate. Keeping transactional and marketing traffic on separate reputations is a real operational idea, and the specialist that markets it hardest has written the clearest explanation of why — linked at the end, with the obvious caveat that they wrote it.

Templates you can render before anyone receives them

This is the part that gets skipped in DX comparisons, and it’s the part that catches bugs. A server-side template with declared variables can tell you which ones you forgot, before the mail goes out.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/template/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"name":"welcome-dx-compare","subject":"Welcome to {{product}}, {{first_name}}","html":"<p>Hi {{first_name}}, your {{product}} workspace is ready.</p>","variables":["first_name","product"]}'
{
  "ok": true,
  "data": {
    "template_id": "tmpl_qw407eYX1mZOSCIQMm9SFA7Y",
    "name": "welcome-dx-compare",
    "subject": "Welcome to {{product}}, {{first_name}}",
    "html": "<p>Hi {{first_name}}, your {{product}} workspace is ready.</p>",
    "created_at": "2026-07-25T15:54:00.544057Z",
    "variables": ["first_name", "product"]
  }
}

Now render it with a deliberately incomplete variable set:

curl -sS -X POST "https://api.infrai.cc/v1/email/template/preview/tmpl_qw407eYX1mZOSCIQMm9SFA7Y" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"vars":{"first_name":"Dana"}}'
{
  "ok": true,
  "data": {
    "rendered_subject": "Welcome to {{product}}, Dana",
    "rendered_html": "<p>Hi Dana, your {{product}} workspace is ready.</p>",
    "missing_vars": ["product"]
  }
}

missing_vars is the assertion you want in a unit test. In our testing the unresolved token is left in place rather than blanked, so a missed variable ships as literal {{product}} in the subject line of a real welcome email — a failure mode every templating system has, and one that a preview call turns into a red test instead of a support ticket.

React Email’s answer to the same problem is a component and a storybook. That’s genuinely nicer to build in, and if your team lives in JSX you’ll probably prefer it. The trade-off is that your email markup is compiled into your application, so changing a paragraph means a deploy rather than a PATCH /v1/email/template/update/{id}.

Sending the welcome message

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");

async function post(path, body) {
  const res = await fetch(API + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const payload = await res.json();
  if (!res.ok || payload.ok === false) {
    const e = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
    throw new Error(`${path} -> ${e.code}: ${e.message}`);
  }
  return payload.data;
}

export async function sendWelcome({ email, firstName, product }) {
  const templateId = process.env.WELCOME_TEMPLATE_ID ?? "tmpl_qw407eYX1mZOSCIQMm9SFA7Y";
  const vars = { first_name: firstName, product };

  const preview = await post(`/v1/email/template/preview/${templateId}`, { vars });
  if (preview.missing_vars.length) {
    throw new Error(`welcome template missing: ${preview.missing_vars.join(", ")}`);
  }

  const sent = await post("/v1/email/send", {
    to: email,
    from: "welcome@mail.example.com",
    template_id: templateId,
    template_vars: vars,
  });

  if (sent.suppressed_recipients?.length) {
    console.warn(`suppressed, not delivered: ${sent.suppressed_recipients.join(", ")}`);
    return null;
  }
  return sent.message_id;
}

const id = await sendWelcome({ email: "user@example.com", firstName: "Dana", product: "Acme" });
console.log("queued as", id);

Two things about that flow are worth copying whichever vendor you land on. Preview before send, and treat suppression as a normal outcome rather than an error — an address that hard-bounced last month coming back through your signup form is routine, and the send returns it in suppressed_recipients instead of throwing.

The equivalent curl, if you’d rather see the wire format:

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":"welcome@mail.example.com","template_id":"tmpl_qw407eYX1mZOSCIQMm9SFA7Y","template_vars":{"first_name":"Dana","product":"Acme"}}'

Then check the state of a specific message — the path takes the message_id the send returned:

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

US and EU, and what none of this solves

Region matters for two separate reasons and people conflate them. Data residency is a legal question about where message content sits at rest; delivery reputation is an operational question about which IPs the receiving MTA has seen before. Both specialists let you pick a region for the first; none of the three, ours included, lets you outrun the second — a brand-new sending domain gets throttled everywhere until it has history.

What Infrai gives you here is a number instead of a shrug: GET /v1/email/domain/get/{domain} returns reputation.current_daily_cap and used_today, so warm-up is a value you can read and alert on. That’s a reporting difference, not a physics one.

What it costs, and how to read today’s number

The shape is the part worth internalising, because it’s what makes the two models behave differently at your volume — not the digits, which move.

QuestionInfrai emailBoth specialists above
What you buyA meterA monthly tier by volume
Unitper_email, one charge per accepted recipientIncluded volume, then overage
Rate today$0.00046 per email — $0.46 per 1,000Tier price ÷ included volume
At 500 sends a monthYou pay for 500You pay for the tier
When a batch job triples the monthLinearYou hit a ceiling and re-tier
Free tier$2 of trial credit, no monthly minimumFree tier with a monthly cap
Free foreverDomain, template, suppression and event callsVaries by plan

The Infrai rate above is dated 2026-07-27 and published as approximate. Read the current one rather than trusting a paragraph:

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'))"

So which one

If deliverability support is what you’re buying — a person who reads your DMARC aggregates and tells you why Outlook is foldering you — stick with Postmark. That’s a service, not an API, and no aggregator resells it.

If your team wants email templates to be React components and you’re happy with one vendor relationship, Resend direct is the shortest path, and you skip a layer.

Infrai makes sense when email isn’t the only thing you’re integrating. The welcome mail is usually step three of five, and the other four are already on the same account: POST /v1/queue/publish for the job that sends it, PUT /v1/storage/object/put/{bucket}/{key} for the rendered artefact, POST /v1/cron/create for the day-3 nudge, POST /v1/errors/capture for the morning your DNS breaks. No second account, no second onboarding, and per-tenant cost stays one GET /v1/account/usage query rather than a reconciliation across four dashboards.

The honest limitation is how much vendor choice that leaves you. POST /v1/email/send does take a vendor field, but only one value is routable on the western region right now — the others are published as pending rather than ready — so if your reason for choosing a provider is a particular IP pool, you’d be better off contracting with them directly.

References

Browse more email developer guides