DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our inbound mail webhook answers 200 to everything it refuses, and 500 to the one thing it cannot do

Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. The website around it, notifio.app, has no inbox, no helpdesk and no ticket system. It has one API route.

Mail addressed to the domain lands at Resend, Resend posts a webhook, and the route forwards the message to a human mailbox. You can check the first half of that from a terminal:

$ dig +short MX notifio.app
10 inbound-smtp.eu-west-1.amazonaws.com.

$ dig +short TXT send.notifio.app
"v=spf1 include:amazonses.com ~all"
Enter fullscreen mode Exit fullscreen mode

Inbound on the apex, outbound on a subdomain. The second half is src/app/api/webhooks/resend/route.ts, 179 lines, and the only interesting thing about it is which status code each decision returns.

Last week it grew by six lines:

// DMARC reports stay in Resend rather than being forwarded
if (/dmarc/i.test(String(subject ?? ""))) {
  console.log("[resend-webhook] Not forwarding a DMARC report");
  return;
}
Enter fullscreen mode Exit fullscreen mode

A return with no value, inside a function whose caller then answers 200. That is the whole fix, and it is the fifth thing in this route that declines to do any work and reports success anyway.

The thing a webhook status code actually means

A webhook is not a request from a user. Nobody is looking at the response. The status code is not information for a person, it is an instruction to the sender's retry queue, and there are only two instructions available:

  • 2xx: stop sending me this.
  • 5xx: send it again later.

Once that is the frame, "did I do the work" and "what do I return" come apart completely, because most of the reasons not to do work are permanent. A message addressed to somewhere we do not read will still be addressed there on the fourth delivery. Retrying cannot fix it, so asking for a retry is just asking to be woken up three more times for the same answer.

So the route is written as a ladder of refusals, and the question at each rung is not "is this an error" but "would doing this again produce a different outcome".

The ladder

Bad signature, 401. The only response in the file that is both a refusal and a failure:

try {
  payload = resend.webhooks.verify({ ... });
} catch (verifyError) {
  console.error("[resend-webhook] Signature verification failed:", verifyError);
  return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
Enter fullscreen mode Exit fullscreen mode

A 401 is honest here and no retry logic will help, because whatever sent that body cannot sign it. If it was the real sender with a rotated secret, the loud 401s in the log are exactly what you want to see.

There is an else branch under that, which exists for local development:

} else {
  // If no webhook secret configured, parse the body directly (dev mode)
  payload = JSON.parse(rawBody);
}
Enter fullscreen mode Exit fullscreen mode

Worth being clear-eyed about: that is an unauthenticated code path gated on an env var being absent. It is the kind of line that becomes a security post on somebody else's blog if the variable goes missing in production. The mitigating facts are that the route's only effect is forwarding mail to one hardcoded address, and that the next rung throws out anything not addressed to an address we own.

An event we do not act on, 200. Resend sends more than inbound mail:

case "email.delivered":
case "email.bounced":
case "email.delivery_delayed":
case "email.complained":
  // These events are logged but don't require action
  console.log(`[resend-webhook] Event: ${type}`, data);
  break;

default:
  // Unknown event type - ignore
  console.log(`[resend-webhook] Unknown event: ${type}`);
  break;
Enter fullscreen mode Exit fullscreen mode

Four known events that are logged and dropped, and a default that swallows anything added to the product later. A route that 500s on an event type it has never seen is a route that fills somebody's dashboard with failures the day the vendor ships a feature.

A recipient we do not read, 200.

const allowedAddresses = [
  "ceo@notifio.app",
  "hello@notifio.app",
  "support@notifio.app",
  "notifications@notifio.app",
  "noreply@notifio.app",
  "no-reply@notifio.app",
];

if (!allowedAddresses.includes(firstRecipient)) {
  console.log(`[resend-webhook] Ignoring email to unauthorized address: ${firstRecipient}`);
  return; // Silently ignore unauthorized addresses
}
Enter fullscreen mode Exit fullscreen mode

Six addresses, lowercased, exact match. Anything else is dropped. Two of those six are noreply@ and no-reply@, which is the part people find odd: why accept mail at an address whose name says not to use it? Because that is the address our transactional mail comes from, and a real person replying to an activation email should reach a human rather than a bounce. The address promises nobody is watching. The route quietly makes that a lie, in the direction that helps.

A DMARC report, 200. The new one. DMARC aggregate reports are machine-to-machine mail: a compressed XML file, sent on a schedule by mailbox providers, describing how messages claiming your domain authenticated. Useful to read, with no reason at all to arrive as a notification in the same inbox as customer email. They are still in Resend, which holds every inbound message anyway, so nothing is lost by not forwarding them.

The check is a case-insensitive substring on the subject line, and I want to name what that costs rather than pretend it is free: a human writing to support with the subject "DMARC question" is now dropped silently, with one line in a log. The same two-line fix with the filter applied to the sender, or to the presence of an application/gzip attachment named like a report, would not have that hole. The reason the crude version shipped is that the honest version of the risk is "a renter emailing about a Dutch rental site is unlikely to put DMARC in the subject", and the contact route most people actually use is the form on notifio.app/help, which does not come through this path at all.

Raw mail we cannot fetch, 500. The only rung that asks for a retry:

const { data: email } = await resend.emails.receiving.get(email_id);

if (!email?.raw?.download_url) {
  throw new Error("Raw email not available");
}
Enter fullscreen mode Exit fullscreen mode

This one is different in kind from every refusal above it. The message is for us, it is from a person, it is not a report, and we want it. We just cannot read it this second. That throw lands in the outer catch, which returns 500, and Resend tries again later. The same is true of the forward itself:

if (sendError) {
  console.error("[resend-webhook] Failed to forward email:", sendError);
  throw new Error(`Failed to forward email: ${sendError.message}`);
}
Enter fullscreen mode Exit fullscreen mode

A send failure is a transient failure of a thing we decided to do, so it becomes a 5xx and the delivery is replayed. The flip side, which is the price of using status codes this way, is that a retried delivery can forward the same mail twice. For a support inbox, a duplicate email is a non-event. For a route that charged a card, it would not be, which is why the Stripe webhook on the same site is built around unique columns rather than around hoping deliveries are unique.

Two config lines that are load bearing

export const dynamic = "force-dynamic";
export const revalidate = 0;
// Allow up to 30s for the 3-step email fetch → parse → forward pipeline
export const maxDuration = 30;
Enter fullscreen mode Exit fullscreen mode

Thirty seconds, because the work is three sequential network hops: ask Resend for the message, download the raw source, send the forward. The download has its own tighter budget:

const rawResponse = await fetch(email.raw.download_url, {
  signal: AbortSignal.timeout(10_000), // 10s timeout for raw email download
});
Enter fullscreen mode Exit fullscreen mode

Ten seconds of the thirty, spent on the slowest step, with the remaining twenty for parsing and sending. If that abort fires it throws, which means 500, which means retry, which is the correct answer for a slow network and the whole reason the timeout is shorter than the function's limit. A fetch with no signal inside a function with a hard ceiling gets you a platform timeout instead, and platform timeouts are worse because they happen after you have spent the budget rather than when you decide to stop.

Why this route survives a database outage

There is one more thing that treats this endpoint differently from the rest of the site. Notifio has a maintenance switch that answers 503 to everything needing Postgres, and three routes are listed as exempt:

const NO_DATABASE_ROUTES = ["/api/download", "/api/contact", "/api/webhooks/resend"];
Enter fullscreen mode Exit fullscreen mode

Downloads, the contact form, and this. The logic is the same as the status-code logic: during a database outage, the single most likely inbound email is somebody telling us the thing is broken, and a 503 on that path means Resend holds their message until the outage is over. Keeping the one route that carries bad news alive is cheap, because it never needed the database in the first place.

The rule, in one line

Return 2xx when you have decided not to act, and 5xx only when you wanted to act and could not. Everything in this file follows from that, including the six lines added last week, which look like an exception handler and are actually a decision.

If you want to see the product the inbox belongs to, it is notifio.app/download for the app itself and notifio.app/alerts for the sites it watches. The earlier post about this same route, two angle brackets breaking every inline image, covers the parsing half rather than the control flow.

Top comments (0)