Sending an email with the Resend API is one POST to https://api.resend.com/emails with a bearer token, a from address, a to address, a subject, and a body. The hard part is not the request. It is everything around the request: where the key lives, whether the receiving server trusts your domain, and what happens when the call fails at 3am.
Transactional email looks like a solved problem until the first password reset does not arrive. This walks the whole path — the call, the domain setup that decides whether it lands, the failure handling that most integrations skip, and where each piece belongs in a deployed application.
Table of contents
- The minimum call
- Domain verification is the whole game
- Where the API key belongs
- Idempotency, retries, and the 3am failure
- Webhooks: knowing what happened after you sent
- Running the pieces together
- How this fits the rest of the stack
- FAQ
The minimum call
Every SDK wraps the same HTTP request. Here it is unwrapped, because knowing the shape helps when you are debugging a 422.
curl -X POST 'https://api.resend.com/emails' \
-H 'Authorization: Bearer re_xxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"from": "Acme <[email protected]>",
"to": ["[email protected]"],
"subject": "Reset your password",
"html": "<p>Click the link to reset.</p>"
}'
And through the Node SDK, which is what most applications will actually use:
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
from: 'Acme <[email protected]>',
to: ['[email protected]'],
subject: 'Reset your password',
html: '<p>Click the link to reset.</p>',
});
if (error) {
// Do NOT throw here and lose the request. See the failure section.
console.error('send failed', error);
}
Note the destructured error rather than a thrown exception. The SDK returns errors in the result object. A try/catch alone will not catch a rejected send, and integrations that assume it will silently drop mail.
Domain verification is the whole game
You can send immediately from the provider’s sandbox domain. You cannot send to real users from it, and anything you learn from testing there tells you nothing about deliverability.
Verifying your own domain means publishing DNS records, and each one answers a different question a receiving mail server asks.
- SPF — a TXT record listing which servers may send on behalf of your domain. Answers: is this server allowed to claim it is you?
- DKIM — a public key in DNS matching a signature on each message. Answers: was this message actually signed by you, and is it unmodified?
- DMARC — a policy record saying what to do when SPF or DKIM fail, plus a reporting address. Answers: what should I do when the first two do not line up?
- Return-Path / MAIL FROM alignment — a subdomain record that makes bounce handling and SPF alignment work together.
Publish all of them. A missing DKIM record is the single most common reason mail from a correctly-written integration lands in spam.
Start DMARC permissive and tighten it once the reports are clean:
; Week one — observe only, break nothing
_dmarc.yourdomain.com. TXT "v=DMARC1; p=none; rua=mailto:[email protected]"
; Once reports show SPF and DKIM aligned for all legitimate senders
_dmarc.yourdomain.com. TXT "v=DMARC1; p=quarantine; pct=100; rua=mailto:[email protected]"
; Eventually
_dmarc.yourdomain.com. TXT "v=DMARC1; p=reject; rua=mailto:[email protected]"
Do not jump straight to p=reject. If your CRM, invoicing tool, or support desk also sends as your domain and you never listed them, you have just started rejecting your own mail.
Where the API key belongs
In an environment variable on the service. Not in the repository, not in a config file that happens to be gitignored, not in the frontend bundle.
The frontend point is worth dwelling on, because it is the mistake that costs money. An API key in client-side JavaScript is public. Anyone can read it, and anyone can then send mail as your domain — which burns your sending reputation, not just your quota.
The correct shape is always: browser calls your backend, your backend calls the email API.
// app/api/contact/route.js -- server-side only
import { Resend } from 'resend';
export async function POST(request) {
const { email, message } = await request.json();
// Validate before spending an API call
if (!email || !message) {
return Response.json({ error: 'missing fields' }, { status: 400 });
}
const resend = new Resend(process.env.RESEND_API_KEY);
const { error } = await resend.emails.send({
from: 'Contact <[email protected]>',
to: ['[email protected]'],
replyTo: email,
subject: 'New contact form submission',
text: message,
});
return error
? Response.json({ error: 'send failed' }, { status: 502 })
: Response.json({ ok: true });
}
Use scoped keys where the provider offers them — a sending-only key cannot list your domains or read your account. And rotate the key when someone leaves the team, which is easier when it is one environment variable rather than a value pasted into four places.
Idempotency, retries, and the 3am failure
Email APIs fail. Networks time out, providers rate-limit, and a request that times out may or may not have sent. The naive retry sends the invoice twice.
Idempotency keys solve this. You supply a key with the request; the provider returns the original result rather than sending again if it sees the same key.
await resend.emails.send(
{
from: 'Billing <[email protected]>',
to: [customer.email],
subject: `Invoice ${invoice.id}`,
html: renderInvoice(invoice),
},
{ idempotencyKey: `invoice-${invoice.id}` },
);
The deeper fix is not sending from the request path at all. A password reset that fails because the email provider had a bad thirty seconds should not fail the user’s request. Write the intent to a queue or a table, return success, and let a worker do the sending with retries and backoff.
This is the difference between an integration that works and one that is reliable, and it is where most of them stop short. The worker needs somewhere to run and something durable to read from — a background service and a managed Postgres, which is the same shape as any other job queue. The database documentation covers the persistence half.
Webhooks: knowing what happened after you sent
A 200 from the send call means the provider accepted the message. It does not mean it was delivered, opened, or that the address exists. Those facts arrive later, as webhooks.
email.sent— handed to the receiving serveremail.delivered— accepted by the recipient’s mail serveremail.bounced— hard or soft failure; hard bounces should suppress the addressemail.complained— marked as spam by the recipient; suppress immediately, alwaysemail.opened/email.clicked— engagement, and less reliable than they look given image proxying
Handle bounces and complaints or your reputation degrades. Continuing to send to an address that hard-bounced is the clearest possible signal to a receiving provider that you are not maintaining your list. Store a suppression list and check it before sending.
Verify webhook signatures before trusting the payload — an unauthenticated webhook endpoint is an open invitation to have your suppression list poisoned. And return a 2xx quickly; do the real work asynchronously, because providers retry on timeout and you will get duplicates.
Running the pieces together
A working transactional email setup is four moving parts: the application that decides to send, the queue or table that holds the intent, the worker that sends with retries, and the webhook endpoint that records the outcome.
On RunxBuild that maps to a web service for the app, a managed Postgres for the queue and suppression list, and a second service for the worker — each with its own plan, environment variables, and runtime logs. The logs matter more than usual here, because email failures are asynchronous and the only trace is what you wrote down.
Keep the sending domain’s DNS somewhere you can edit quickly. Deliverability problems are usually DNS problems, and the debugging loop is much shorter when publishing a corrected record does not require a support ticket.
How this fits the rest of the stack
The API call is ten lines. The reliability is in the DNS records that make receiving servers trust you, the queue that keeps a provider outage from failing user requests, and the webhook handler that stops you sending to dead addresses. Get those three right and email stops being a source of mystery bug reports. If you are sizing an app with a worker and a database behind it, the RunxBuild hosting calculator shows the service, the worker, and the database as separate line items rather than one number.
Useful related references:
- Deploy a Node.js API for Free on RunxBuild
- Deploy a .NET API for Free on RunxBuild
- Namecheap API for Developers: The Part the Docs Skip
- Services on RunxBuild
FAQ
Where should I store the Resend API key?
In an environment variable on the server-side service. Never in the repository and never in client-side JavaScript — a key in the browser bundle is public, and anyone who reads it can send mail as your domain and destroy your sending reputation.
Why is my email going to spam even though the API returns 200?
A 200 means the provider accepted the message, not that it was trusted. Publish SPF, DKIM, and DMARC records for your sending domain. A missing DKIM record is the most common cause of correctly-sent mail landing in spam.
How do I stop duplicate emails when retrying a failed send?
Pass an idempotency key derived from the thing you are notifying about, such as invoice-1234. The provider returns the original result instead of sending again if it sees the same key.
Should I send email directly from the request handler?
Prefer not to. If the provider is slow or down, the user’s request fails for a reason unrelated to what they asked for. Write the intent to a queue or table, return success, and let a worker send with retries.
What do I do with bounce and complaint webhooks?
Maintain a suppression list and check it before every send. Hard bounces and spam complaints should suppress the address permanently — continuing to send to them is the fastest way to damage your domain reputation.