Portreeve
how-to · 7 Sept 202612 min read

Resend API from Node: verify, send, don't send twice

Wire the Resend API into Node: DNS records that verify a domain, a raw fetch send, idempotency keys so retries don't duplicate, and signed bounce webhooks.

Most Resend integrations do not fail at the send call. They fail around it: a half-verified domain, a job queue that retries, and bounces nobody is listening for.

This wires the Resend API into a Node service end to end. A verified sending domain, a send you can read without an SDK, the same send with an idempotency key so a retried job does not email someone twice, and a signed webhook handler that records bounces instead of dropping them.

The example that motivates all of it is the one most founders build first: a message in your inbox when someone signs up, useful enough that you act on it.

Prerequisites

  • Node 18.17 or newer and a service that can expose one HTTP route.
  • A Resend account and an API key (re_...). Keys are per environment; make a separate one for production.
  • Control of a domain's DNS. Not a shared or vendor-owned domain.
  • A job queue or any way to run work outside the request. BullMQ, a cron worker, Inngest, a database-backed table. Anything with a retry.

1. Resend domain verification: what SPF, DKIM and DMARC each prove

Resend will not send from your domain until three things resolve, and each one answers a different question a receiving mail server asks.

SPF answers "is this server allowed to send for this domain". It is a TXT record listing authorized senders, defined in RFC 7208, and the receiver checks it against the envelope sender (the return path) rather than the From: header you see in the client.

DKIM answers "was this message altered in transit". Your DNS holds a public key at resend._domainkey; Resend signs each message with the private half, and the receiver recomputes the signature over the headers and body per RFC 6376. A changed subject line breaks it.

DMARC answers "what should I do when the first two disagree with the From: header". It also introduces alignment: SPF or DKIM passing is not enough, the domain that passed has to match the domain in From:.

The records Resend generates follow that shape. An MX record on the send subdomain pointing at feedback-smtp.<region>.amazonses.com with priority 10, a TXT record on the same send host holding v=spf1 include:amazonses.com ~all, and a TXT record at resend._domainkey holding the public key. The return path subdomain defaults to send, which is why the SPF and MX records live there and not on the apex.

Two things people get wrong here.

The send subdomain is the return path, not your From: address. If you add acme.com to Resend, you send from [email protected] and the records sit under send.acme.com. If you instead add updates.acme.com as the domain, your From: has to be @updates.acme.com or DMARC alignment fails. Resend recommends a subdomain to isolate sending reputation, which in practice means a bad marketing campaign cannot drag your password resets down with it.

And "verify later" stopped being an option in February 2024. Google's sender guidelines require SPF or DKIM from every sender, a valid PTR record, TLS, and a spam rate under 0.3% in Postmaster Tools. Send close to 5,000 messages a day to personal Gmail accounts and the bar goes up: SPF and DKIM, a DMARC record, alignment, and one-click unsubscribe on marketing mail. Yahoo enforced the same authentication rules on the same schedule without naming a volume threshold. Add the DMARC record now, at _dmarc.acme.com, with v=DMARC1; p=none; rua=mailto:[email protected]. Read the reports for a month before tightening p.

Verify: the domain reads Verified in the Resend dashboard. DNS propagation can take a few hours; a green checkmark on two of three records means you are not done.

2. One POST from Node, no SDK

The whole send is a single request. Worth seeing once before you let a package hide it.

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: "Acme <[email protected]>",
    to: ["[email protected]"],
    subject: "New signup: [email protected]",
    html: "<p>[email protected] just signed up.</p>",
    text: "[email protected] just signed up.",
    reply_to: "[email protected]",
  }),
});

if (!res.ok) throw new Error(`resend ${res.status}: ${await res.text()}`);
const { id } = await res.json();

from, to and subject are the only required fields, and to accepts up to 50 addresses. If you omit text, Resend generates it from your HTML; sending both is still worth the two lines because some filters weigh a missing plain-text part. The response body is one field, the email id, which is the handle you will match against webhook events later. Store it.

The default rate limit is 10 requests per second per team, shared across every API key you own, which is another reason sends belong in a worker where you can control concurrency.

Verify: run this against a real address, get a 200 and an id back, then find that id in the Emails tab of the dashboard with a delivery status next to it.

3. The same send with the SDK

npm install resend
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: "New signup: [email protected]",
  html: "<p>[email protected] just signed up.</p>",
});

if (error) {
  // error.name is the machine-readable code; error.message is prose
  throw new Error(`${error.name}: ${error.message}`);
}
console.log(data.id);

The SDK returns { data, error } rather than throwing, so a bare await with no check will happily continue past a failed send. It is the bug I see most often in Resend code.

What the SDK buys you: typed payloads, resend.batch.send() for up to 100 messages in one call, React Email components as the react field instead of an HTML string, attachment encoding, and a webhook verifier you will use in section 5. The single send is the fetch call from the previous section. Use whichever you prefer, but know which one you are choosing.

4. Resend idempotency keys, so a retry does not send twice

Almost every tutorial ships this shape, and it is wrong:

app.post("/signup", async (req, res) => {
  const user = await createUser(req.body);
  await resend.emails.send({ /* ... */ });   // don't
  res.json({ ok: true });
});

That couples your signup route's latency and failure to a third-party API. Worse, the moment you move the send into a queue (which you will, because signups should not 500 when an email API is slow), your retries become duplicate emails. A worker that crashes after the send but before the ack sends again on the next attempt.

Resend's answer is the Idempotency-Key header. Pass a key, and if a request with that key arrived in the last 24 hours, you get the original response back instead of a second send. Resend normalizes and hashes the request body alongside the key, so reusing a key with different content is reported to you rather than silently swallowing a different email.

The key must come from the event, not from the attempt. A crypto.randomUUID() generated inside the worker is new on every retry and defeats the entire mechanism. Resend recommends <event-type>/<entity-id>:

const { data, error } = await resend.emails.send(
  {
    from: "Acme <[email protected]>",
    to: ["[email protected]"],
    subject: `New signup: ${user.email}`,
    html: body,
  },
  { idempotencyKey: `signup-alert/${user.id}` },
);

Keys are 1 to 256 characters and live for 24 hours. Three failure modes are worth handling, all documented on Resend's idempotency page:

  • 400 invalid_idempotency_key: the key is empty or over 256 characters.
  • 409 invalid_idempotent_request: same key, different payload. Retrying is pointless; this is a bug in how you derive the key. Log it loudly.
  • 409 concurrent_idempotent_requests: another request with this key is still in flight. Safe to retry with backoff.

The 24-hour window is a real constraint. A weekly digest keyed digest/user_42 will send once and then go quiet forever if you assume the key blocks duplicates permanently. Key it with the period: digest/user_42/2026-W36.

Verify: fire the same send twice in a row. The second call returns the id from the first, and the Emails tab shows one message.

5. Resend webhooks: bounces only arrive if you ask for them

A 200 from the send endpoint means Resend accepted the message. It says nothing about whether a mailbox took it. That verdict comes back over a webhook or not at all.

Create the endpoint in the dashboard, subscribe to email.bounced, email.complained and email.delivered, and copy the signing secret. Resend signs with Svix, which means three headers (svix-id, svix-timestamp, svix-signature) verified against the raw request body. Any JSON body parser mounted ahead of this route re-serializes the payload and breaks the signature.

import express from "express";
import { Webhook } from "svix";

const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET);

app.post(
  "/webhooks/resend",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let evt;
    try {
      evt = wh.verify(req.body, {
        "svix-id": req.get("svix-id"),
        "svix-timestamp": req.get("svix-timestamp"),
        "svix-signature": req.get("svix-signature"),
      });
    } catch {
      return res.sendStatus(400);
    }

    res.sendStatus(200);                      // ack first, work after
    void handleEmailEvent(evt, req.get("svix-id"));
  },
);

If you would rather not add a second dependency, the Resend SDK ships resend.webhooks.verify() over the same headers and raw body.

Resend retries a non-200 at 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, and tells you to store processed svix-id values and skip duplicates. Delivery is at-least-once, so your handler has to be idempotent, same as your sends.

async function handleEmailEvent(evt, svixId) {
  if (await alreadyProcessed(svixId)) return;

  if (evt.type === "email.bounced" && evt.data.bounce?.type === "Permanent") {
    // subType is Suppressed, MessageRejected, and similar
    await markUndeliverable(evt.data.to[0], evt.data.bounce.subType);
  }
  if (evt.type === "email.complained") {
    await unsubscribeEverything(evt.data.to[0]);
  }

  await recordProcessed(svixId);
}

A permanent bounce is a fact about the address. Set a flag on the user row and stop sending. A Temporary bounce (a full mailbox, a greylisting server) resolves on its own and should not disable anything. Treat a complaint as harder than a bounce: someone pressed the spam button, and continuing to mail them is how you spend the 0.3% Gmail allows you.

Verify: Resend's [email protected] address returns an SMTP 550 5.1.1. Send to it, watch your handler fire, confirm the row flips.

6. The signup alert, end to end

The route creates the user, screens it, and enqueues. It does not send.

app.post("/signup", async (req, res) => {
  const user = await createUser(req.body);

  const screen = await portreeve.verdict({
    event_type: "signup",
    external_user_id: user.id,
    ip: req.ip,
    device_token: req.body.device_token,
    email: user.email,
  });

  if (screen.verdict === "block") {
    return res.status(403).json({ error: "Unable to create account" });
  }

  await queue.add("signup-alert", {
    userId: user.id,
    verdict: screen.verdict,
    reasons: screen.reasons,
  });

  res.json({ ok: true });
});

The worker sends, keyed on the user id:

worker.process("signup-alert", async ({ data }) => {
  const user = await getUser(data.userId);

  const { error } = await resend.emails.send(
    {
      from: "Acme <[email protected]>",
      to: ["[email protected]"],
      subject: `New signup: ${user.email}`,
      html: `<p><strong>${user.email}</strong> on ${user.plan}</p>
             <p>Screening: <code>${data.verdict}</code>
                (${data.reasons.join(", ") || "no flags"})</p>`,
    },
    { idempotencyKey: `signup-alert/${user.id}` },
  );

  if (error) throw new Error(error.name);   // let the queue retry; the key holds
});

The screening line is what makes the notification worth reading. Portreeve is the abuse firewall I built for this: one API call at signup returns allow, review or block plus reason codes, so the email lands in your inbox reading verdict: review (disposable_email, datacenter_ip) next to the address. A review never blocks the person signing up; the flow proceeds and the event goes to a queue in the dashboard where you decide later. The reason code reference lists what each flag means and handling verdicts covers what to do with each one. device_token comes from the browser snippet (device fingerprinting); leave it out and the call still works.

Because the idempotency key is signup-alert/${user.id}, throwing on error is safe. The queue retries, Resend recognizes the key, and you get one email.

Verify: sign up against a test key, kill the worker mid-job, let the queue retry. One message in your inbox with a verdict on it.

7. When it lands in spam anyway

Send yourself a message and open the raw headers. Gmail's "Show original" gives you a three-line verdict.

If dkim=pass header.d= shows a domain different from the one in From:, that is an alignment failure and DMARC will treat the message as unauthenticated even though DKIM passed. Usually it means you verified acme.com but are sending from @mail.acme.com, or the reverse.

If spf=pass names amazonses.com rather than your domain, check that the SPF TXT record is on send.acme.com and not the apex.

If everything passes and mail still sorts into spam, your problem is content and reputation. A brand new domain has no sending history, and a first message to Gmail that is one line of HTML wrapped around a link is hard to tell apart from the average phish. Warm up gradually, keep transactional and marketing mail on separate subdomains, and give every message a plain-text part.

Before you point this at production

Move the API key to a production-only key so you can revoke a leaked one without taking staging down. Put the webhook secret somewhere your handler can read it before the first request, not lazily. Add a dead-letter path for signup-alert jobs that exhaust their retries, because a silently dropped alert is worse than a duplicate.

The two mistakes that will cost you an afternoon: mounting express.json() above the webhook route, and deriving idempotency keys from anything that changes between attempts. Both fail quietly.

If you want the verdict line in your signup emails, Portreeve's free tier screens 1,000 events a month with no card. Create an account and the first call takes about five minutes.

← Back to all posts