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 afterPOST /emailsreturns.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 afteremail.sent.email.bounced— the recipient's mail server rejected the message. This fires instead ofemail.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:
- Real delivery rate. You can show "delivered" in a UI and have it actually mean delivered, not "we tried."
- 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.