This is a different failure mode from treating email.sent as delivered. That bug is here. This post is about what happens after you store the Resend email ID.
POST /emails returned 200. You saved the Resend email ID on your notification row. A minute later the webhook handler runs.
Then support asks: "Why does the dashboard say delivered when the address bounced?"
The send call worked. The reconciliation layer did not.
The bug is usually a flat status field
Most handlers start like this:
app.post("/webhooks/resend", async (req, res) => {
const event = req.body;
const emailId = event.data.email_id;
const row = await db.notifications.findByResendId(emailId);
if (!row) return res.sendStatus(200);
if (event.type === "email.delivered") {
await db.notifications.update(row.id, { status: "delivered" });
}
if (event.type === "email.opened") {
await db.notifications.update(row.id, { status: "opened" });
}
if (event.type === "email.bounced") {
await db.notifications.update(row.id, { status: "bounced" });
}
res.sendStatus(200);
});
Three separate problems hide in that shape:
-
Lookup failure — webhook arrives before your app finishes persisting the Resend ID, or the ID is stored on the wrong table. Handler returns
200, nothing updates, bug disappears until production. -
Last-write-wins —
email.openedoverwritesdelivered. Fine if you track engagement separately. Bad ifstatusdrives UX copy or retry logic. -
No terminal states —
email.bouncedshould win overdeliveredwhen events arrive out of order or retry. A single string field cannot express that without rules.
Separate delivery state from engagement
A pattern that survives production:
const TERMINAL = new Set(["bounced", "complained", "failed"]);
function nextDeliveryState(current, incoming) {
if (TERMINAL.has(current)) return current;
if (incoming === "bounced" || incoming === "complained") return incoming;
if (incoming === "delivered") return current === "sent" ? "delivered" : current;
if (incoming === "sent") return current ?? "sent";
return current;
}
app.post("/webhooks/resend", async (req, res) => {
const { type, data } = req.body;
const emailId = data.email_id;
const row = await db.notifications.findByResendId(emailId);
if (!row) {
await db.webhookInbox.insert({ provider: "resend", emailId, type, payload: req.body });
return res.sendStatus(200);
}
if (type === "email.opened" || type === "email.clicked") {
await db.emailEvents.insert({ notificationId: row.id, type });
return res.sendStatus(200);
}
const next = nextDeliveryState(row.deliveryStatus, type.replace("email.", ""));
await db.notifications.update(row.id, {
deliveryStatus: next,
lastEventType: type,
lastEventAt: new Date(),
});
res.sendStatus(200);
});
Notes that matter in real apps:
-
email.openedis not delivery. Log it separately unless your product truly treats opens as delivery confirmation. -
Store unmatched webhooks. If the event arrives before the send transaction commits, you need a replay path — not a silent
200. - Idempotency key = Resend email ID + event type. Retries are normal.
The test most teams skip
Unit tests mock one webhook payload. That proves parsing, not reconciliation.
The integration test that catches the bug:
- create internal notification row
-
POST /emailsand persist Resend email ID on that row - deliver
email.sent, thenemail.delivered - deliver
email.openedand assert delivery status did not regress - deliver
email.bouncedon a second send and assert terminal state wins
Step 4 and 5 are where flat status fields break.
Receipts
Real Resend send workflow run against FetchSandbox, captured 2026-06-07:
POST /emails → 200
id: 4d9acb24-5913-4e02-8b1a-a18ed10a6fad
GET /emails/4d9acb24-5913-4e02-8b1a-a18ed10a6fad → 200
webhook events verified:
email.sent
email.delivered
Full timeline: fetchsandbox.com/runs/8e4fe8e9f9?flow=run_4062510e-268e-451f-b5f4-e8844eef42d5
That run proves the provider lifecycle through email.delivered. Your app still needs its own test for mapping those events back to the correct internal row and for terminal bounce/complaint handling.
Runnable preflight (optional)
Before wiring production webhooks, I run the send workflow from Cursor with FetchSandbox MCP:
validate resend email workflow with fetchsandbox
Workflow page: fetchsandbox.com/docs/resend
Resend sandbox page (mock API + webhook replay): fetchsandbox.com/resend
That does not replace Resend's real domain, API key, or production webhook endpoint. It just makes the lifecycle obvious before you commit your local state machine.
What to read next
- email.sent is not delivered — conflating early send events with delivery
- Resend webhook event docs — field shapes for each event type
If you are integrating Resend this week, fix ID lookup and terminal states before you polish the send form.
Top comments (0)