Mailerflow Start free
Blog › Transactional Email & Developer Tools › Email Webhooks

Debugging Webhook Deliveries: Why Email Events Go Missing

Published October 14, 2026 · 7 min read

Illustration for Debugging Webhook Deliveries: Why Email Events Go Missing

Events go missing for one of a handful of reasons, and webhook debugging is mostly about narrowing down which one before you touch code. A signed webhook with delivery logs gives you a per-attempt record — event type, target URL, HTTP response code, response time, attempt number, next retry — so you can classify the failure instead of guessing. The three symptoms developers actually report are: events never arrive, events arrive late, and events arrive twice. Each points at a different layer, and the log tells you which.

Start With the Symptom, Not the Code

"Never arrive" usually means the request never reached your handler, or it reached it and was rejected before your code ran. That is an endpoint reachability or routing problem. "Arrive late" means the request succeeded but took longer than the sender's timeout, so the sender marked it failed and retried — you see the event eventually, sometimes more than once. "Arrive twice" is almost always a retry after a slow or non-2xx response, not a duplicate send on the provider side.

Classify first. If you open your editor before you know which of the three you have, you will spend an hour adding logging to a handler that was never called. Pull up the delivery log for the event ID in question and read the attempt history.

What a Delivery Log Actually Tells You

A delivery log entry is a record of one HTTP attempt, not one event. That distinction matters: a single event can produce five log entries if it was retried five times. Here is an illustrative excerpt (example data, not from a real account):

2026-03-04T09:14:02Z  evt_8f21a  email.delivered   POST https://api.example.com/hooks/mail
  attempt 1  ->  200  142ms

2026-03-04T09:14:07Z  evt_8f21b  email.bounced     POST https://api.example.com/hooks/mail
  attempt 1  ->  500  38ms   body: "TypeError: Cannot read properties of undefined (reading 'email')"
  attempt 2  ->  500  41ms   next retry in 60s

2026-03-04T09:14:11Z  evt_8f21c  email.opened      POST https://api.example.com/hooks/mail
  attempt 1  ->  timeout after 10000ms
  attempt 2  ->  200  210ms

Two fields get skipped constantly. The attempt number tells you whether retries are even happening — if you see attempt 1 and nothing else, the sender either succeeded or gave up, and you need to know which. The response body snippet often contains the exact stack trace you would have spent twenty minutes reproducing locally.

Most Likely Causes, in Order of Probability

  1. Your endpoint returns a non-2xx. An unhandled exception, an auth middleware that rejects the request before your route runs, or a method mismatch. This is the single most common cause because it is the easiest to introduce accidentally.
  2. Your endpoint is too slow. The sender times out before your handler finishes. Synchronous database writes, an outbound API call, or a cold serverless start all push you past the limit.
  3. URL or routing misconfiguration. A staging URL left in the production config, a trailing slash that your router treats as a different path, or an HTTP URL that redirects to HTTPS.
  4. A firewall, WAF, or IP allowlist blocks the sender. The request dies at the network edge and never appears in your application logs.
  5. Signature verification fails and you reject valid events. Usually because you verify against a re-serialized JSON object instead of the raw bytes.
  6. Retries were exhausted while your service was down. A deploy window or an outage that outlasted the retry schedule.
  7. A duplicate delivery is mistaken for a missing one. You are looking at the wrong event ID, or you processed the event but did not record it.

How to Diagnose Each Cause

Non-2xx responses

Read the response code and body in the delivery log. Then reproduce it: take the payload from the log and replay it against your endpoint with curl. If it fails locally too, you have a handler bug. If it succeeds locally, the difference is in headers, auth, or the environment.

Slow endpoints

Compare the logged response time against your own handler timing. If the log shows a timeout but your application logs show the handler completing in 50ms, the request is being held somewhere upstream. If your handler genuinely takes 12 seconds, look for synchronous writes or outbound calls inside the request path.

Misconfiguration

Diff the target URL in the log against your deployed configuration. Check for a trailing slash, a www prefix, or a path segment that changed in the last deploy. This is the fastest cause to rule out and the most embarrassing to miss.

Firewall or allowlist

Check whether the request reaches your server at all. If your access logs show nothing for that timestamp, the request was dropped before your application. tcpdump on the interface, or a temporary catch-all route, will confirm it.

Signature failures

Log the raw request body before any parsing middleware touches it, then verify the signature against those exact bytes. Most signature bugs come from verifying a re-serialized object whose key order or whitespace differs from what was signed.

Exhausted retries

Check the retry window and whether your downtime overlapped it. If your service was down for 40 minutes and the retry schedule spans 30, the events are gone unless the provider offers replay.

Duplicates

Search by event ID. If the same ID appears twice, it is a retry and your handler is not idempotent. If two different IDs carry the same logical change, the provider sent two distinct events.

Fixes, Step by Step

Return 2xx immediately, process asynchronously. Queue the payload, respond, then do the work. The handler's only job is to accept and acknowledge.

Make the handler idempotent using the event ID. Store processed event IDs and skip anything you have already handled. This makes retries and duplicates harmless.

Verify signatures against the raw body. Capture the raw bytes before JSON parsing and verify against those.

Fix routing and allowlist the sender's published source ranges. If you must redirect, redirect before the webhook path, not on it.

Add a dead-letter queue or replay path. Anything that exhausts retries should land somewhere you can inspect and reprocess.

A minimal Express sketch covering the first three fixes:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Capture raw body for signature verification
app.post('/hooks/mail',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.get('X-Webhook-Signature');
    const expected = crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(req.body)          // raw bytes, not parsed JSON
      .digest('hex');

    if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body);
    queue.enqueue(event);        // respond now, work later
    res.status(200).end();
  }
);

Reading Response Codes Without Guessing

Code Meaning What to do
2xx Accepted Nothing. Stop retrying.
3xx Your endpoint is redirecting Fix the URL. Senders often treat 3xx as failure.
401 / 403 Auth or signature rejected Check the secret and the raw-body handling.
404 Wrong path Diff the URL against your routes.
405 Wrong method Confirm you accept POST.
413 Payload too large Raise your body limit.
5xx Your code threw Read the response body; retries will continue.
timeout Handler too slow or path blocked Profile the handler; check the network path.

A 3xx is the quiet killer. A redirect from HTTP to HTTPS looks harmless in a browser and breaks webhook delivery silently, because the sender records it as a failed attempt.

How to Prevent It Next Time

Monitor your delivery-log failure rate and alert on it — a rising 5xx rate is a deploy problem you can catch in minutes instead of days. Keep a canary endpoint that always returns 200, so you can separate provider-side issues from your own. Test signature verification in CI with a captured fixture rather than a hand-built payload. Load-test the handler for burst traffic, since a campaign send can produce a spike. Version your event schema and tolerate unknown fields so a new event type does not crash the parser. Document the retry window and your replay procedure before you need them.

If you are building this from scratch, Mailerflow's signed real-time webhooks include retries and delivery logs, which is where you read all of the above; the same platform exposes a transactional email API on a high-priority lane with templates and variables, and the developer docs cover scoped keys, idempotency keys, an OpenAPI spec, code samples and a JS SDK.

When to Ask Your Provider for Help

Bring four things: the event ID, the target URL, the timestamp window, and the delivery-log entries. With those, the provider can confirm whether attempts were made, what response codes came back, and what the retry schedule was.

What they cannot do is debug your handler. If the logs show repeated 5xx from your side, the fix is yours. If the logs show no attempt at all for an event you believe was generated, that is a provider-side question worth raising. If the logs show attempts that stopped after the retry window closed, ask whether replay is available.

FAQ

Why do I get the same event twice? Almost always a retry after a slow or non-2xx response. Use the event ID as an idempotency key so the second delivery is a no-op.

Why did events stop after a deploy? Either the target URL or auth changed, or the handler now throws before it can respond. Check the delivery log for the first failing attempt and compare it to your deploy timestamp.

How long do retries continue? It depends on the provider's retry window. Check your provider's documentation and record the number somewhere your team can find it during an incident.

Can I replay missed events? Only if the provider offers a replay feature, or if you kept a dead-letter queue of the payloads you received. Without one of those, exhausted events are usually gone.

Do I need to verify signatures? Yes, if your endpoint is reachable from the public internet. Without verification, anyone who learns your URL can post fake events into your system.

If you want a webhook setup where retries, signatures and delivery logs are handled for you, start with the Mailerflow transactional email API — scoped keys, idempotency and an OpenAPI spec, free to try.