Skip to main content

Resend

Resend's `email.sent` webhook doesn't mean the email was delivered

POST /emails returns 200. The email.sent webhook fires. Your app marks the email delivered. Then a third of your password resets never reach the inbox. Here's why those two events are not the same thing.

FetchSandbox EngineeringFetchSandbox Engineering

The send endpoint returns 200. The webhook handler receives email.sent. The app updates the local record to delivered. The customer support queue fills with "I never got the email" tickets.

The bug is treating email.sent and email.delivered as the same event. They aren't. Your OpenAPI client won't tell you the difference because both look identical from a schema perspective. The lifecycle is the contract.

What each event actually means

Resend fires several webhook events for a single email. The three that matter most:

  • email.sent — Resend handed the message off to the outbound SMTP layer. The email left Resend. It hasn't reached the recipient's mail server yet. This fires almost immediately after POST /emails returns.
  • email.delivered — the recipient's mail server accepted the message. This is the "the email arrived" event. It can fire a few seconds to a few minutes after email.sent.
  • email.bounced — the recipient's mail server rejected the message. This fires instead of email.delivered, not in addition to it.

email.sent is "we tried." email.delivered is "it worked." Marking the email as delivered when email.sent fires is the same shape of bug as marking a Stripe PaymentIntent as paid at confirm time — you're committing to an outcome before the outcome is known.

The code that ships this bug

The handler looks correct:

app.post("/webhooks/resend", (req, res) => {
  const event = req.body;
  if (event.type.startsWith("email.")) {
    await markEmailDelivered(event.data.email_id);
  }
  res.sendStatus(200);
});

It treats any email.* event as success. So email.sent flips the local record to delivered, email.delivered flips it again (no-op), and email.bounced also flips it to delivered — because the substring match doesn't distinguish.

The fix

Branch on event.type. Each branch maps to a distinct local state:

app.post("/webhooks/resend", async (req, res) => {
  const event = req.body;

  switch (event.type) {
    case "email.sent":
      await updateEmail(event.data.email_id, { status: "sent" });
      break;
    case "email.delivered":
      await updateEmail(event.data.email_id, { status: "delivered" });
      break;
    case "email.bounced":
      await updateEmail(event.data.email_id, {
        status: "bounced",
        bounce_type: event.data.bounce?.type,    // hard | soft
        bounce_reason: event.data.bounce?.message,
      });
      // soft bounce: maybe retry. hard bounce: stop sending to this address.
      break;
    case "email.complained":
      await updateEmail(event.data.email_id, { status: "complained" });
      await suppressRecipient(event.data.to[0]);  // CAN-SPAM
      break;
    case "email.opened":
    case "email.clicked":
      // engagement, not delivery — usually fine to ignore in the
      // delivery handler and log separately
      break;
  }

  res.sendStatus(200);
});

Two things this gives you that the substring-match version doesn't:

  1. Real delivery rate. You can show "delivered" in a UI and have it actually mean delivered, not "we tried."
  2. Honest failure handling. A hard bounce is signal that the address is bad — you should stop sending to it. A soft bounce might retry. A complaint must suppress the recipient or your sender reputation tanks.

Receipts

Real Resend send-email workflow run against the FetchSandbox sandbox, captured 2026-06-07:

POST /emails                                       → 200
  id: 4d9acb24-5913-4e02-8b1a-a18ed10a6fad

GET  /emails/4d9acb24-5913-4e02-8b1a-a18ed10a6fad  → 200

webhook events fired:
  email.sent           ← Resend handed off to outbound SMTP
  email.delivered      ← recipient mail server accepted

Full timeline: fetchsandbox.com/runs/8e4fe8e9f9?flow=run_4062510e-268e-451f-b5f4-e8844eef42d5

Two distinct webhook types, one workflow. Your handler needs to discriminate. The email ID 4d9acb24-5913-4e02-8b1a-a18ed10a6fad is the value both webhooks reference — that's your dedupe key when status callbacks arrive out of order.

Why we wrote this

If your UI shows "Delivered" and your support queue tells you otherwise, the gap is almost always between email.sent and email.delivered. The OpenAPI spec for Resend's /emails endpoint can show you the request and response shape; it can't tell you that two webhook events later, the carrier might still reject the message.

We use FetchSandbox to run the send workflow and watch every webhook event fire in sequence before writing the handler — so the local state machine matches Resend's, not a guess at it.