"The send API returned 200" is not the same as "the email arrived." A 200 guarantees only that Resend accepted your request. Everything interesting happens afterwards: the receiving MTA's verdict, SPF and DKIM validation, the spam folder, hard bounces, and complaint-driven suppression. And when you don't get a 200 and have designed nothing for it, that message is gone forever — the user sees "sending failed" and you never learn who was trying to reach you or why.
Production email has to satisfy three conditions: it arrives (domain authentication and deliverability), you can trace it (webhooks and structured logs), and it never goes out twice (idempotency keys). These are not three unrelated tricks. They form a single design.
I run this portfolio site itself on Resend — the contact form, the resource opt-in, a seven-part email course delivered via scheduled sends, and post-payment fulfilment after Stripe checkout are all live paths. Along the way I also caused a real outage: I put Resend's domain-authentication records on the apex instead of the send subdomain, and /api/contact returned 502 for every submission. Fixing it took the DNS correction plus a sender fallback, exponential-backoff retries, and idempotency keys. This article is that design, turned into a map.
Each theme has its own deep-dive article (this one is the cluster's entry point — the pillar). All code here matches the type definitions of resend@6.4.1, the version this repository runs in production.
First, update your mental model of Resend
Articles from 2024–2025 — and AI-generated code trained on them — contain things that break when you run them today. Fix this first.
| Outdated belief (discard) | What is true in 2026 (per the docs) |
|---|---|
| Manage contacts with Audiences | Audiences is deprecated — the SDK's type definitions say @deprecated Use segments instead. Build on Segments. A Contact is now a single global entity per team and can belong to several Segments |
| Resend is just a send API | It now has Templates, Topics, Segments, Suppressions, Automations, and inbound receiving. Received mail is read through emails.receiving |
Verify webhooks by running npm install svix | The first-class path is the SDK's own resend.webhooks.verify(). The verifier ships inside the SDK, so there is nothing extra to install. Which verifier it bundles varies by version (6.4.1 uses svix, 6.18.1 uses standardwebhooks), yet the headers you read and the call you make are identical — so never write code that assumes the bundled library |
The only header you need is Authorization | A User-Agent header is mandatory. Without it the request is rejected with 403 (error code 1010) before it ever reaches the API. SDKs and the CLI set it for you; raw fetch integrations do not |
| Plans are Free / Pro / Enterprise | Four tiers: Free, Pro, Scale, Enterprise. Dedicated IPs are a paid add-on gated to Scale |
| The free tier means "100 sends per day" | It is 100/day and 3,000/month across sends and inbound receipts combined, and each To, CC and BCC recipient counts as a separate email |
| Rate limits are per API key | 10 requests per second per team. Every API key on the team shares one pool, and there is no burst allowance |
send() throws when it fails | It does not. It always resolves to { data, error } (plus headers in v6). Only two other paths throw — see below |
Every figure above comes from the official pages as read on 6 August 2026. Specifications and prices change, so confirm current values on the relevant official page before you commit.
One more honest note: the latest resend on npm as of that date is 6.18.1, and it exposes suppressions and automations as SDK resources. 6.4.1, the version this article targets, does not. From that version you either reach the suppression API through the generic resend.post() / resend.get() methods or you upgrade the SDK.
The architecture: where things break
Email is hard to debug because five things can break and your application only ever sees the first one. Start with the map.
[Your application]
│ (1) POST https://api.resend.com/emails
│ Authorization: Bearer re_xxx / User-Agent: required / Idempotency-Key: optional
▼
[Resend API] ── auth → validation → idempotency lookup → queue → 200 { id }
│ (2) SMTP handoff (envelope signed with SPF/DKIM)
▼
[Receiving MTA (Gmail / Outlook / corporate mail server)]
│ (3) accept / defer / hard-reject (bounce) / classify as spam
▼
[Inbox, or spam folder, or bounce, or complaint]
│ (4) bounce and complaint feedback
▼
[Resend] ── auto-adds the address to Suppressions and skips future sends
│ (5) Webhook POST (signed, at-least-once, no ordering guarantee)
▼
[Your app's /api/resend-webhook] ── verify the signature on the RAW body → return 200
Here is how each point fails and what you actually observe.
| Failure point | Typical cause | What your app sees |
|---|---|---|
| (1) Request | Missing User-Agent, invalid API key, unverified from domain | 403. May not reproduce with local curl |
| (2) Handoff | Misplaced DNS records (SPF/MX on the apex, for example) | 403 validation_error. Domain verification never completes |
| (3) Receiver verdict | Poor reputation, spam classification, hard bounce | Still a 200. Completely invisible from the application |
| (4) Feedback | Rising complaint rate, suppression-list entry | Later sends to that address come back as suppressed |
| (5) Webhook | Signature check fails because the body was parsed, 5xx responses, endpoint auto-disabled | State never updates. Bounces go unnoticed |
Point (3) can never be detected from the API response. That is exactly why webhooks are not a nice-to-have but the observability layer that separates a one-off send script from a production system.
Sending in five minutes
1. Create an API key
You can create keys from the dashboard, the API, the CLI, or the MCP server. You pick a permission (full_access or sending_access), and a sending_access key can be narrowed further to a single domain. The rule is simple: a service that only sends mail should never hold full_access. Key names max out at 50 characters, and the value is shown exactly once.
2. Understand the resend.dev limitation
Before you verify a domain you can send using the shared onboarding@resend.dev sender — but it is test-only and delivers exclusively to the email address on your Resend account. Anything else returns a 403 with this body:
You can only send testing emails to your own email address (your-email-address@domain.com).
To send emails to other recipients, please verify a domain at resend.com/domains, and change
the `from` address to an email using this domain.
Note that Resend has no sandbox and no production-approval process. Even free accounts have full production access from signup — a concrete difference from AWS SES.
3. The minimal code
npm install resend
import { Resend } from "resend";
// Omitting the argument makes the SDK read process.env.RESEND_API_KEY.
// But the constructor THROWS when no key is present, so production code
// should not construct the client at module scope (see below).
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: "Acme <onboarding@resend.dev>",
to: ["delivered@resend.dev"],
subject: "hello world",
html: "<p>it works!</p>",
});
// The SDK does not throw on API errors. Always branch on error.
if (error) {
console.error(error.name, error.statusCode, error.message);
} else {
console.log(data?.id);
}
Testing against made-up addresses damages your own bounce rate. Use Resend's dedicated addresses instead: delivered@resend.dev (delivered), bounced@resend.dev (bounce), complained@resend.dev (complaint), suppressed@resend.dev (suppressed).
At least one of html, text or react is required, and the type system enforces it. Omit text and Resend generates a plain-text part from your HTML; pass an empty string to opt out of that behaviour.
Seven gates on the way to production
This is the substance of the article: the seven gates between "it works" and "I would trust this in production." Implementation detail for each lives in its own post.
Gate 1: Domain authentication and deliverability
You cannot avoid verifying your own domain, and the most common failure is putting the records in the wrong place. The SPF-related records Resend generates (an MX pointing at feedback-smtp.<region>.amazonses.com and a TXT holding v=spf1 include:amazonses.com ~all) go on the send subdomain, not the apex. DKIM's TXT goes at resend._domainkey, and DMARC's TXT at _dmarc. That asymmetry is what trips people up — Resend's own troubleshooting page lists "verify that the records are added at the correct location (the send subdomain, not the root domain)" as check number two.
| Record | Correct location | Never put it here |
|---|---|---|
SPF MX (feedback-smtp.*) | send.example.com | apex |
SPF TXT (v=spf1 …) | send.example.com | apex |
DKIM TXT (p=…) | resend._domainkey.example.com | apex, send. |
DMARC TXT (v=DMARC1 …) | _dmarc.example.com | send. |
| Tracking CNAME | your configured tracking subdomain | apex |
DNS providers expect the relative name (send, not send.example.com). Some providers also append your domain to MX values, producing feedback-smtp.eu-west-1.amazonses.com.example.com and a failed verification; the fix is a trailing period on the value so it is treated as a fully qualified name.
My outage was exactly this: DKIM, SPF, MX and DMARC all sitting on the apex. The symptom was "every form submission returns 502." The domain was never verified, so sends were rejected with a 403, and with no retry path in place every enquiry simply vanished.
Also worth knowing: Resend signs with 1024-bit DKIM keys and does not support 2048-bit, and publishes its reasoning (RFC 8301 sets 1024 bits as the minimum verifiers must support). If your security team mandates 2048-bit, check before you commit.
Finally, the account-level thresholds: bounce rate under 4%, spam rate under 0.08%. Exceeding either can pause your sending.
The full DNS record set, how to walk DMARC from p=none to quarantine to reject, and how tracking subdomains behave are covered in the Resend domain authentication and deliverability guide.
Gate 2: A type-safe send path
You cannot call the Resend API from a browser. api.resend.com returns no Access-Control-Allow-Origin, so a client-side call always fails CORS. That is a guardrail, not a bug — had it succeeded, your API key would have been public. So the key lives in RESEND_API_KEY and never gets a NEXT_PUBLIC_ prefix (anything with that prefix is inlined into the client bundle).
In the Next.js App Router the send belongs inside a Route Handler. Three implementation points matter.
// app/api/contact/route.ts (excerpt from this site's production code)
export const dynamic = "force-dynamic";
let resendClient: Resend | null = null;
// (1) Lazy initialisation. A module-scope `new Resend(...)` throws when the key
// is missing, taking the whole build or render down with it.
function getResend(): Resend {
if (!resendClient) {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) throw new Error("RESEND_API_KEY is not configured");
resendClient = new Resend(apiKey);
}
return resendClient;
}
(2) SDK options are camelCase (replyTo, scheduledAt, topicId). The REST API uses snake_case and the SDK converts for you — but keys outside its whitelist are silently dropped, so a stray reply_to can typecheck in some shapes and still never reach the API.
(3) template and html / text / react are mutually exclusive at the type level. When you send with a template, from and subject become optional.
And the two throwing paths are worth repeating. One is the constructor above. The other is the react: option when @react-email/render — an optional peer dependency since v5 — is not installed; that raises a plain Error, not an { error } envelope. It is precisely why the official Next.js sample wraps the call in try/catch.
The complete handler, including Zod validation, rate limiting, a spam gate and structured logging, is in the Next.js App Router and Resend implementation guide. For the rate limiter itself see rate limiting in serverless Next.js, and for the form side React Hook Form with Server Actions.
Gate 3: Idempotency and retries
Every send that might be retried needs an idempotency key. Resend supports Idempotency-Key on POST /emails and POST /emails/batch: a repeat with the same key within 24 hours is not actually sent and returns the original response. Keys are 1–256 characters, and the recommended shape is <event-type>/<entity-id> — for example welcome-user/123456789.
const { data, error } = await resend.emails.send(
{
from: "Acme <notifications@mail.example.com>",
to: [order.email],
subject: "Thanks for your order",
html,
},
// Second argument. A custom header inside the payload deduplicates nothing.
// Derive it from something deterministic, like the payment event id.
{ idempotencyKey: `order-receipt/${order.id}` },
);
With the key in place, the next question is how to decide whether to retry — and the answer is to branch on the error name, not the HTTP status. The reason is concrete: one status code, 409, carries two opposite meanings.
| Error name | HTTP | Meaning | What to do |
|---|---|---|---|
rate_limit_exceeded | 429 | Per-second request limit exceeded | Back off and retry |
application_error / internal_server_error | 500 | Transient fault on Resend's side | Back off and retry |
concurrent_idempotent_requests | 409 | A request with the same key is still in flight | Safe to retry later |
invalid_idempotent_request | 409 | Same key, different payload | Retrying is useless. Suspect a bug |
invalid_idempotency_key | 400 | Key outside the 1–256 character range | Fix the key (or resend without one) |
validation_error | 403 | Sending from an unverified domain, etc. | Fix the sender; retries will not help |
daily_quota_exceeded / monthly_quota_exceeded | 429 | Sending quota reached | Retrying inside the same window is pointless |
When you see invalid_idempotent_request, look for Date.now() or Math.random() leaking into the payload. "Same key, different body" is almost always non-determinism in your own code.
On this site that table is encoded directly as a pure function.
// lib/email-delivery.ts (excerpt) — an unlisted name falls back to the HTTP status;
// only a missing status (fetch threw, so nothing was ever sent) counts as retryable.
const RESEND_ERROR_KINDS: Readonly<Record<string, DeliveryErrorKind>> = Object.freeze({
rate_limit_exceeded: "retryable",
application_error: "retryable",
internal_server_error: "retryable",
concurrent_idempotent_requests: "retryable",
invalid_from_address: "sender_rejected",
validation_error: "sender_rejected",
});
The third classification, sender_rejected, is the interesting one. "Retry the same thing", "retry something different", and "give up" are genuinely different actions, and collapsing the middle case is what loses leads. Swapping once to a known-good sender when the configured one is refused is what turned my outage from "the form is dead" into "a warning line in the logs."
One caveat worth flagging: the official error table and the SDK constant disagree on invalid_from_address (the docs say 422, the SDK constant says 403). I will not claim either is correct — but if you classify by name and treat the status as supplementary, that kind of drift cannot hurt you.
On idempotency in general, I have kept a payment platform at zero double-charges in production on the same principle: retry safety comes down to whether the sender can produce a deterministic key. Testable retry functions, timeout design and how to verify backoff are covered in the Resend idempotency, retry and error-handling guide, with the payment side in the Stripe production guide.
Gate 4: Templates
Hard-coding email bodies means a deploy for every copy change. Resend Templates are managed from the dashboard or the API, and only published templates can be used for sending — a newly created template is a draft.
// create() is PromiseLike and exposes .publish(), so you can do both in one chain
await resend.templates.create({ /* … */ }).publish();
To send, you pass template.id (the id or an alias) and variables. The constraints are explicit: at most 50 variables per template (the figure in the create/update API references and the "Working with Variables" page; only the dashboard's Templates introduction page still says 20, so trust the API side), keys limited to ASCII letters, digits and underscores with a maximum of 50 characters, and string values capped at 2,000 characters. FIRST_NAME, LAST_NAME, EMAIL and UNSUBSCRIBE_URL are reserved and cannot be used. If a single variable used by the template is missing, the send fails validation.
If you would rather write bodies as typed components, React Email (the react: option) is the alternative. Resend's own rule is to pass the component as a function call, not as JSX — WelcomeEmail({ name: "John" }). When to choose which, and the practical design work, is in the Resend templates and React Email design guide.
Gate 5: Batch sends, scheduling, and subscriptions
Batch sending goes through resend.batch.send() with up to 100 emails per request — and because one batch request counts as a single request against the rate limit, it is the biggest throughput lever available. Two constraints: attachments are not supported, and under the default batchValidation: 'strict' a single invalid entry fails the whole request. Switch to 'permissive' and the valid entries are sent while failures come back as errors: { index, message }[].
Scheduling uses scheduledAt with either ISO 8601 or natural language ("in 1 hour", "tomorrow at 9am"), up to 30 days ahead. You can reschedule with emails.update() and cancel with emails.cancel(), but cancellation is a one-way door — a cancelled email cannot be rescheduled. And there is a trap that is easy to miss: deleting the API key used to schedule an email prevents that email from being sent. Build that into your key-rotation runbook.
This site's email course creates all seven messages as scheduled sends and embeds the later messages' ids in a signed unsubscribe token, so opting out calls emails.cancel() on the remainder. No database, no cron.
For subscriptions, {{{RESEND_UNSUBSCRIBE_URL}}} (triple braces) is the merge tag Broadcasts and Automations expand into a per-recipient unsubscribe link. For transactional mail you add a List-Unsubscribe header yourself. RFC 8058 one-click also requires List-Unsubscribe-Post: List-Unsubscribe=One-Click, a blank 200 or 202 in response to the POST, and that you stop sending within 48 hours. Gmail and Yahoo require this of bulk senders above 5,000 messages a day.
await resend.emails.send({
from: "Acme <news@mail.example.com>",
to: [contact.email],
subject: "This week's update",
html,
headers: {
"List-Unsubscribe": `<https://example.com/unsubscribe?t=${token}>`,
// Absent from the official code sample, but required for RFC 8058 compliance
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
});
Batch chunking strategy, rollback for scheduled sends, and implementing subscriptions with Segments and Topics are covered in the Resend batch, scheduled sending and subscription guide.
Gate 6: Learning the outcome through webhooks
As established, the API response tells you nothing about what happened at the receiver. Webhooks are how delivery, bounces, complaints, delays and suppressions become visible.
The implementation hinges on one thing: the raw body.
import { Resend } from "resend";
import { type NextRequest, NextResponse } from "next/server";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
const secret = process.env.RESEND_WEBHOOK_SECRET;
if (!secret) return new NextResponse("Not configured", { status: 500 });
// The raw body string. Parsing with req.json() and re-stringifying
// breaks the signature.
const payload = await req.text();
try {
// verify is synchronous and THROWS on failure — it is not { data, error }
const event = resend.webhooks.verify({
payload,
headers: {
id: req.headers.get("svix-id") ?? "",
timestamp: req.headers.get("svix-timestamp") ?? "",
signature: req.headers.get("svix-signature") ?? "",
},
webhookSecret: secret,
});
// Branch on event.type from here
return new NextResponse(null, { status: 200 });
} catch {
return new NextResponse("Invalid webhook", { status: 400 });
}
}
The official sample does not work as printed. NextRequest.headers is a Headers instance, so req.headers['svix-id'] is undefined — you need .get(). Worse, a different official page writes payload: JSON.stringify(req.body), which is the exact anti-pattern the verification page warns against. The code above corrects both.
Three operational properties to design around.
- At-least-once delivery: the same event can arrive more than once. Persist the
svix-idheader and skip duplicates. - No ordering guarantee:
email.openedcan arrive beforeemail.delivered. If order matters, sort by the payload'screated_at. - Endpoints get auto-disabled: repeated failures trigger an email notification and eventually automatic disabling; you re-enable from the dashboard once you are back up.
On retry scheduling, three official pages give three different answers (a six-step schedule, an eight-step schedule, and "up to 24 hours"). Rather than pick one, design for "a retry window of several hours up to about a day."
Per-event data shapes, handling permanent versus transient bounces, and the path from complaint to suppression are in the Resend webhooks, signature verification and bounce handling guide. For hardening the endpoint itself, see security headers and CSP in Next.js.
Gate 7: Observability and cost
Because resend@6 responses include headers, you can read your rate limit and quota consumption straight from code.
| Header | Meaning |
|---|---|
ratelimit-limit | Maximum requests allowed within the window |
ratelimit-remaining | Requests left in the current window |
ratelimit-reset | Seconds until the limit resets |
retry-after | Seconds to wait before the next request |
x-resend-daily-quota | Daily sending quota used (sent to free-plan users only) |
x-resend-monthly-quota | Monthly sending quota used |
These follow the sixth IETF draft for rate-limit headers. Retrying on a fixed interval while ignoring retry-after just stacks 429s on top of 429s.
For application logs, emit one line of JSON per event. Hosting log viewers frequently collapse multi-argument console.* calls, which means the detail disappears exactly when you are mid-incident. And never log the submitted content — restrict fields to enums, status codes and counts.
// One line per event; fields stay enums / statuses / counts.
function log(level: "info" | "warn" | "error", phase: string, fields: Record<string, unknown> = {}) {
console[level](JSON.stringify({ route: "contact", phase, ...fields }));
}
Data retention is 30 days on Free, Pro and Scale (flexible on Enterprise), and by default Resend stores your message content. Turning content storage off is a $50/mo add-on available only to teams that meet all three conditions: at least one month on Pro or Scale, sending from a domain with an active website, and over 3,000 emails sent at a bounce rate under 5%. If you send medical or financial content, plan for that from the start rather than assuming it.
Separate transactional from marketing
Do not send password resets and newsletters from the same domain. Complaints attach to the domain, not to the content type. A rising complaint rate on your newsletter drags down the deliverability of your password resets; the reverse never happens, because nobody reports a password reset as spam. The risk is strictly one-directional, which means there is no argument for leaving them together.
Resend officially recommends sending from a subdomain rather than the root domain. Split by purpose — account.example.com for transactional, updates.example.com for marketing — and the reputations become independent. Note, though, that each subdomain is a separate domain object you add and verify individually, so the free plan's single-domain allowance cannot host both. This is a genuine reason to move to a paid tier.
Tracking is part of the separation too. Open and click tracking is off by default, and enabling it requires a verified tracking subdomain (a CNAME) — but Resend recommends leaving it off for transactional mail. Link rewriting from click tracking is also a known cause of broken verification links — Supabase's own docs state that link tracking is known for corrupting verification links. Tracking only your newsletter is the healthy configuration.
The subscription model is worth stating plainly. In 2026 Resend has three concepts:
| Concept | Who controls it | Visibility | Role |
|---|---|---|---|
| Contact | The system | Internal | A global entity keyed on an email address |
| Segment | You, the sender | Internal only; recipients never see it | Who you are sending to |
| Topic | The recipient | Shown on the unsubscribe preference page | What you are sending. Recipients can decline by type |
Resend's own phrasing is exact: Segments are for targeting, Topics are for protecting preferences. Send a Broadcast without a Topic and a recipient who unsubscribes is unsubscribed from everything you send. Also note that a Topic's default subscription (opt-in or opt-out) cannot be changed after creation.
Finally, addresses suppressed by a bounce or complaint are skipped across your entire team and all its domains. The important asymmetry: suppression is automatic, but the Contact's unsubscribed flag is not flipped for you. That part is your application's job.
Thinking about cost
Figures from the official pricing page as of 6 August 2026 (Scale has further steps; this is the subset that actually changes a decision). Sending volume (transactional) and contact count (marketing) are priced separately, and you choose a tier for each independently.
| Plan | Monthly | Emails/mo | Daily limit | Custom domains | Overage per 1,000 |
|---|---|---|---|---|---|
| Free | $0 | 3,000 | 100 | 1 | — |
| Pro | $20 | 50,000 | none | 10 | $0.90 |
| Pro | $35 | 100,000 | none | 10 | $0.90 |
| Scale | $90 | 100,000 | none | 1,000 | $0.90 |
| Scale | $350 | 500,000 | none | 1,000 | $0.70 |
| Scale | $650 | 1,000,000 | none | 1,000 | $0.65 |
| Enterprise | Custom | Custom | none | Flexible | Custom |
At the same 100,000 emails a month, Pro is $35 and Scale is $90. The premium does not buy volume — it buys Slack support, 1,000 domains, 500 AI credits, and eligibility for a dedicated IP. Confusing the two is a straightforward way to overspend.
Volume is not the only cost axis:
- Overages: paid plans bill in buckets of 1,000 emails and stop hard at 5× your monthly quota (changeable via support). You must opt in explicitly under "Transactional Overages" in team settings.
- Automation Runs: 10,000 per month included on every plan; beyond that, $0.0015 per run on paid plans.
- AI credits: 5 on Free, 100 on Pro, 500 on Scale, flexible on Enterprise. They reset monthly and do not roll over.
- Dedicated IPs: a $30/mo add-on, but the requirements are strict — the Scale plan plus over 3,000 emails sent per day. Resend itself documents when they do not help: under roughly 90,000 emails a month the IPs cannot stay warm, inconsistent volume hurts reputation, and Resend does not expose the IP list so you cannot use them for allowlisting. They are not a deliverability silver bullet.
One more billing nuance: the pricing-page FAQ says "we only provide monthly plans," while the knowledge base says annual subscriptions are available for Enterprise. The correct reading is self-serve is monthly only; annual exists for Enterprise through sales.
Comparisons with SES, SendGrid and Postmark, plus the break-even points at real volumes, are in choosing an email delivery service.
Pre-launch checklist
This is what I actually check before putting Resend into production.
- Custom domain verified (SPF MX and TXT on
send, DKIM atresend._domainkey, DMARC at_dmarc) - Transactional and marketing split across sending subdomains, with tracking left off on the transactional side
- API keys default to
sending_accessscoped to a domain;full_accessonly where genuinely needed -
RESEND_API_KEYlives only in server env vars and carries noNEXT_PUBLIC_prefix - The Resend client is lazily initialised (no
new Resend(...)at module scope) - Every retryable send carries a deterministic
idempotencyKey - Retry decisions branch on the error name, not the status code
- There is a path that does not lose the lead when a send fails (sender fallback, queue, or a resend route)
- Webhooks are verified against the raw body and deduplicated on
svix-id - Webhook handling assumes no ordering guarantee
- Bounce and complaint events also update subscription state inside your own app
- Batch sends account for the 100-per-request ceiling and the
strictversuspermissivedifference - Bulk mail carries
List-UnsubscribeandList-Unsubscribe-Post, and can stop sending within 48 hours - Logs are single-line JSON and contain no PII
- Rate limits (10 req/s per team) and quota consumption are monitored from the response headers
- Volume estimates account for "sends plus inbound receipts" and "each To/CC/BCC recipient is one email"
Conclusion: wire the three conditions into one design
Resend really is fast to the first successful send. The gap to production comes down to three things.
- It arrives: SPF on the
sendsubdomain, DKIM atresend._domainkey, DMARC at_dmarc. Separate transactional and marketing domains. Watch the 4% bounce and 0.08% complaint thresholds. - You can trace it: verify webhooks against the raw body, deduplicate on
svix-id, and advance state without assuming ordering. Log single-line JSON with no PII. - It never goes out twice: attach a deterministic
idempotencyKey, branch retries on the error name, and never confuse the two meanings of 409.
And one more thing. Always have a path that does not end at "sending failed." What I lost during my 502 outage was technically the position of one DNS record; commercially it was enquiries that should have reached me. Retries, sender fallbacks and structured logging all exist for that reason.
Implementation detail for each theme lives in the other posts in this cluster. Start by opening your own DNS and checking whether the SPF record sits on send rather than the apex.
This article is based on the official Resend documentation (Emails API, Domains, Webhooks, Segments and Topics, Pricing — as of August 2026) and on the type definitions of the installed
resend@6.4.1, restructured with production judgement added. Specifications and prices change, so confirm current values on the relevant official pages before adopting them in production.