Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

The Resend API: Sending Transactional Email From a Deployed App

Sean

Platform Writer

Aug 10, 2026
8 min read

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.

The Resend API: Sending Transactional Email From a Deployed App

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

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 <noreply@yourdomain.com>",
    "to": ["user@example.com"],
    "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 <noreply@yourdomain.com>',
  to: ['user@example.com'],
  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:dmarc@yourdomain.com"

; Once reports show SPF and DKIM aligned for all legitimate senders
_dmarc.yourdomain.com. TXT "v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc@yourdomain.com"

; Eventually
_dmarc.yourdomain.com. TXT "v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com"

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 <noreply@yourdomain.com>',
    to: ['support@yourdomain.com'],
    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 <billing@yourdomain.com>',
    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 server
  • email.delivered — accepted by the recipient’s mail server
  • email.bounced — hard or soft failure; hard bounces should suppress the address
  • email.complained — marked as spam by the recipient; suppress immediately, always
  • email.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:

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.

#resend api#transactional email#email api#dkim spf dmarc#webhooks