Use Postmark when transactional email is the entire job and you want predictable inbox placement with the fewest moving parts; reach for Resend when your welcome email templates belong in the same repo and code review flow as the app; keep SendGrid only if something downstream still needs an SMTP relay or a marketing sender on the same account. All three hand you the same core: a JSON send endpoint, somewhere to store templates, a domain verification flow that ends in DKIM records you publish yourself, and a suppression list you can read back.
Nobody migrates providers because of the send call.
I've wired email, SMS and OTP delivery for four different products, and the shape of the work is always the same: the API integration is an afternoon, domain verification and the deliverability groundwork eat a week, and the thing that actually hurts in production is where you put the send. So that's where most of this goes, and the vendor grid gets one section.
Should I choose SendGrid, Resend, or Postmark for transactional email and domain verification?
Start with the feedback loop, because it eliminates half the market before you compare anything else. When an address hard-bounces or someone marks your mail as spam, does your system need to react within seconds, or is a nightly reconciliation good enough? Reacting in seconds means a public webhook endpoint, signature verification, replay tolerance, and a queue behind it — real work, and work you own forever. A nightly job that pulls the event list and upserts into your own suppression table is maybe forty lines of Python and it fails safely. I've shipped both. For welcome mail and password resets, the nightly pull has never once been the thing that hurt me.
After that, four things decide it: the shape of the send call, where templates live, whether domain verification lets you rotate DKIM keys on your own schedule, and whether the suppression list is both readable and writable over the API. That last one gets skipped in every comparison post I've read, and it's the one you'll need at 2am when a support ticket says "she never got the reset link" and you need to know whether her address is suppressed and why.
| Option | Send path | Templates | Domain verification | Bounce/complaint feedback | Where it stops fitting |
|---|---|---|---|---|---|
| SendGrid | REST or SMTP | Hosted, Handlebars | Guided domain auth, CNAME-based | Webhooks, or query the activity feed | Large surface area; account model assumes marketing mail too |
| Resend | REST or SMTP | In your repo, or hosted | DNS records you paste, DKIM included | Webhooks | Fewer suppression controls than the older players |
| Postmark | REST or SMTP | Hosted layouts | Per-domain DKIM signature plus Return-Path | Webhooks, or the bounces API | Broadcast and transactional streams must stay separate |
| Amazon SES | REST or SMTP | Bare-bones | Region-scoped identities, easy DKIM rotation | SNS or EventBridge | You build the parts the others hand you |
| Mailgun | REST or SMTP | Hosted, Handlebars | Subdomain-oriented setup | Webhooks, plus events API | Docs assume you know deliverability already |
The alternative that keeps coming up in these threads is Amazon SES, and it deserves the mention. If you already run on AWS, have someone who understands SPF alignment, and are willing to build your own suppression view over SNS notifications, it's the cheapest infrastructure in the list and the most work. That's a genuine trade-off, not a knock.
Twilio is the one to plan for rather than pick today. The moment onboarding grows an SMS step or an OTP, you're spanning two vendors with two reputations and two sets of retry semantics — and OTP delivery gaps behave nothing like email bounces, so don't assume your email observability transfers.
SPF, DKIM, DMARC: the domain verification order that saved me a re-do
Send transactional mail from a subdomain. Not acme.dev — something like mail.acme.dev, dedicated to lifecycle mail, with your marketing blasts on a different subdomain entirely. Reputation is tracked per sending domain, so this is the cheapest insurance you'll ever buy, and it costs you one extra DNS zone entry.
The order matters more than people expect. SPF and DKIM first, verify both actually resolve, send a few dozen real messages over several days, and only then tighten DMARC. Going straight to p=reject with an unverified DKIM record is how you discover that your invoicing cron has been sending through a forgotten relay for two years.
dig +short TXT resend._domainkey.mail.acme.dev
dig +short TXT mail.acme.dev
dig +short TXT _dmarc.acme.dev
Swap the selector for whichever one your provider issued — Postmark and SendGrid each generate their own, and SES gives you three CNAMEs instead of a TXT record. What I check is that the DKIM record resolves from outside my network (not just in the provider's dashboard), that SPF has exactly one v=spf1 record on the sending domain, and that DMARC starts at p=none; rua=mailto:... so I get aggregate reports before I start rejecting anything.
Google's sender guidelines are the practical spec here, and they're worth reading in full rather than in summary. SPF and DKIM are required for every sender. Bulk senders — 5,000 messages a day or more to Gmail addresses — additionally need aligned DMARC, one-click unsubscribe per RFC 8058, and a spam-complaint rate that stays under 0.10%, never touching 0.30%. Welcome and reset mail usually sits well under that volume threshold, but I add List-Unsubscribe and List-Unsubscribe-Post headers to lifecycle mail anyway. Pure receipts don't need it. Onboarding drip sequences absolutely do, and the boundary between those two is exactly the sort of thing product changes without telling you.
Then ramp DMARC over about a month: p=none, read the reports, p=quarantine; pct=25, watch, then reject. Boring on purpose.
The welcome email send that only broke under real traffic
Our signup handler posted the welcome email inline, and it was fine for months. Staging p95 on POST /signup sat around 240 ms.
Then a launch put roughly 90 signups into a single minute on a Friday evening, and p99 went to 4.2 s. Almost none of that was the provider. It was a cold container plus a fresh TLS handshake to the mail API on a connection pool that had nothing warm in it, and the tail only appeared once real concurrency hit — synthetic checks every 60 seconds had kept exactly one connection alive, which is why nine months of monitoring showed nothing. Two requests crossed the mobile client's 5 s timeout, the client retried the signup, and two people got two welcome emails. That last part is what actually generated tickets. I'm still not entirely sure why the tail was that bad rather than merely bad; the honest answer is that I moved the send off the request path instead of finishing the investigation.
So: the HTTP handler writes a row and returns. A worker sends. The worker owns retries, and it checks a local sent_mail row before it does anything, because provider-side deduplication is not something I want to depend on across a retry storm.
import os
import time
import httpx
SEND_URL = "https://api.resend.com/emails"
RETRYABLE = {408, 429, 500, 502, 503, 504}
TEMPLATE = "welcome-v3"
def send_welcome(to_addr: str, first_name: str, signup_id: str) -> str:
"""Runs in a queue worker, never inside the signup request."""
if already_sent(signup_id, TEMPLATE):
return "duplicate-skipped"
payload = {
"from": "Acme <hello@mail.acme.dev>",
"to": [to_addr],
"subject": f"Welcome to Acme, {first_name}",
"html": render(f"{TEMPLATE}.html", first_name=first_name),
"text": render(f"{TEMPLATE}.txt", first_name=first_name),
"headers": {
"List-Unsubscribe": "<https://acme.dev/email/prefs>, <mailto:unsub@acme.dev>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}
auth = {"Authorization": f"Bearer {os.environ['RESEND_API_KEY']}"}
for attempt in range(4):
r = httpx.post(SEND_URL, json=payload, headers=auth, timeout=8.0)
if r.status_code < 300:
message_id = r.json()["id"]
mark_sent(signup_id, TEMPLATE, message_id)
return message_id
if r.status_code not in RETRYABLE:
raise RuntimeError(f"send rejected: {r.status_code} {r.text[:200]}")
time.sleep(min(2 ** attempt, 8) + 0.25)
raise RuntimeError("no send after 4 attempts")
Same call from Node.js, since that's where a lot of signup workers live:
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ from, to: [toAddr], subject, html, text }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { id } = await res.json();
Postmark and SendGrid differ mostly in the envelope: Postmark wants a X-Postmark-Server-Token header and a message stream, SendGrid wants personalizations. The retry policy, the dedupe row and the queue handoff stay identical, which is the argument for keeping your own thin wrapper instead of leaning on a vendor SDK you'd have to rip out later.
Templates, test sends, and the two assertions I keep in CI
Hosted templates win when non-engineers edit copy, and they cost you a second deploy pipeline nobody versions properly. In-repo templates win when copy changes ride along with code, and they mean a marketing hire needs a pull request to fix a typo. I've regretted both, in different companies, for opposite reasons.
What I don't compromise on is the plain-text part. Every send goes out multipart, always.
Two assertions live in CI and they've caught more real problems than any dashboard: every template renders without an unresolved variable for a fixture user whose name contains an apostrophe, and the plain-text body is non-empty and contains the same primary link as the HTML. For anything visual I use a provider sandbox or test key so twenty test runs don't touch a real inbox or my reputation. Add one production canary — a nightly send to a seed address, asserting arrival and DKIM pass — and you'll know about a broken DNS change before your users do.
Where each of these stops being the right answer
Postmark is my default recommendation and it doesn't support the thing many teams want next: broadcast mail on the same stream. That's deliberate on their part, and if your roadmap has a monthly newsletter in it you'll end up with a second vendor or a second product. Stick with SendGrid or Mailgun when transactional and marketing mail genuinely have to share one account, one API key and one billing relationship.
Resend's catch is maturity in the boring corners. The send path and DNS setup are the cleanest of the three, and it lacks the depth of suppression tooling that SendGrid accumulated over a decade. For a two-template onboarding flow, that gap is theoretical. For a product mailing 40 different lifecycle messages with per-category opt-outs, it isn't.
SendGrid is the right answer more often than its reputation suggests — SMTP relay for legacy systems, subusers for multi-tenant products, template versioning with a UI. If your compliance team wants per-tenant sending isolation, that feature exists there and mostly doesn't elsewhere at this tier.
And if none of it fits, Amazon SES plus your own suppression table is a perfectly respectable answer. As far as I can tell, most teams who go that route are happy, they just don't write blog posts about it because there's nothing charming to say about SNS notifications.
Pick on the feedback model and the domain verification story. Not on the send call — that part's a solved problem everywhere.
References
- Google: Email sender guidelines — https://support.google.com/a/answer/81126
- Twilio SMS documentation — https://www.twilio.com/docs/sms
- Resend: send an email — https://resend.com/docs/api-reference/emails/send-email
- Postmark: email API — https://postmarkapp.com/developer/api/email-api
- SendGrid: v3 mail send — https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
- Amazon SES: sending email with the API — https://docs.aws.amazon.com/ses/latest/dg/send-email-api.html
- RFC 8058: one-click unsubscribe — https://www.rfc-editor.org/rfc/rfc8058
- RFC 7489: DMARC — https://www.rfc-editor.org/rfc/rfc7489
Top comments (0)