close

DEV Community

FetchSandbox
FetchSandbox

Posted on

Resend webhook reconciliation: map events to the right email record

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);
});
Enter fullscreen mode Exit fullscreen mode

Three separate problems hide in that shape:

  1. 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.
  2. Last-write-winsemail.opened overwrites delivered. Fine if you track engagement separately. Bad if status drives UX copy or retry logic.
  3. No terminal statesemail.bounced should win over delivered when 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);
});
Enter fullscreen mode Exit fullscreen mode

Notes that matter in real apps:

  • email.opened is 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:

  1. create internal notification row
  2. POST /emails and persist Resend email ID on that row
  3. deliver email.sent, then email.delivered
  4. deliver email.opened and assert delivery status did not regress
  5. deliver email.bounced on 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

If you are integrating Resend this week, fix ID lookup and terminal states before you polish the send form.

Top comments (0)