Receiving a webhook looks trivial: a route, a POST, some JSON. Maybe that's why the same mistakes show up in almost every integration — and they almost always surface in production, at the worst possible moment: an approved payment that never unlocks the product, a charge processed twice, events silently disappearing. This article collects the ten mistakes that break webhook integrations most often, with a direct fix for each one.
If you're still getting familiar with the concept, start with the definitive guide to webhooks — this article assumes you already know the basic event → POST → response flow.
1. Processing everything before responding
The classic mistake: the handler receives the event, generates an invoice, calls two external APIs, sends a confirmation email… and only then responds 200. The problem is that providers enforce short timeouts — usually between 5 and 30 seconds. If the response doesn't arrive in time, the delivery is marked as failed and the event is resent, now competing with the previous processing still in flight. Under load, this turns into a cascade of retries processing the same work in parallel.
Fix: the handler does the bare minimum — verifies the signature, persists the raw event, enqueues — and responds immediately. The heavy lifting happens in a worker, at your own pace:
app.post("/webhooks/payments", async (req, res) => {
if (!isSignatureValid(req)) return res.status(401).end();
await queue.add("process-webhook", {
eventId: req.body.id,
payload: req.body,
});
res.status(200).end(); // acknowledge now; the worker does the rest
});One detail worth the effort: persist the raw event before enqueuing it. If the worker fails, you reprocess from what was saved — without depending on the provider to resend. The database becomes your safety buffer, and the cost is a single INSERT.
2. Not verifying the signature
A webhook endpoint is a public URL that executes business logic. Without signature verification, anyone who discovers (or guesses) the URL can send a forged POST with "type": "payment.approved" — and your application will hand out the product for free. This happens because verification gets treated as "something for later", and later never comes.
Fix: verify the HMAC the provider sends in the header, using a timing-safe comparison, and reject anything that doesn't match with 401. The full mechanism — including replay protection — is explained in Webhook security: HMAC signatures and best practices.
3. Not being idempotent
Retries exist to guarantee at-least-once delivery — which means the same event can arrive twice, three times, ten times. All it takes is your 200 response getting lost on the network for the provider to resend an event you already processed. Without idempotency, the result is a double charge, duplicate emails, inventory decremented twice.
Fix: use the event id as a unique key and drop duplicates at the front door:
INSERT INTO webhook_events (event_id, type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING;
-- 0 rows inserted = duplicate: respond 200 and stopResponding 200 to the duplicate is intentional: the event was already received; having it resent again helps no one.
4. Relying on event order
The network does not preserve order — and retries make it worse. If the first delivery of order.created fails and the retry only goes out two minutes later, the following order.updated arrives first. Code that assumes "created always comes first" breaks in ways that are hard to reproduce.
Fix: two strategies that work well together. Compare the event's timestamp with the state you already have stored and ignore events older than the current state. Or treat the webhook as nothing more than a "something changed on this resource" signal and fetch the current state from the provider's API — the API response is, by definition, the most recent state.
A concrete example of how this bites: a customer pays and then cancels right away. The events go out in the right order — payment.approved, then payment.cancelled — but the first delivery fails, and the retry makes it arrive after the cancellation. Code that applies each event on arrival ends up with a cancelled payment marked as approved. Comparing timestamps would have discarded the late event.
5. Treating the webhook as the source of truth
Unlocking access, decrementing inventory or marking an invoice as paid based solely on the contents of the received POST is betting that the payload is legitimate, current and complete. Even with a valid signature, the event may be stale (a refund may have happened after it) or reflect an intermediate state.
Fix: the webhook notifies; the API confirms. When "payment approved" arrives, look the payment up in the provider's API and decide based on that response. The cost is one extra call; the benefit is never releasing anything valuable based on a notification.
6. Ignoring the provider's retry policy
The status code you return is a conversation with the provider's retry mechanism — and many integrations say the wrong thing. The two symmetrical mistakes: responding 500 to a permanent business error ("order not found"), generating days of useless retries and alerts; and responding 200 while the database is down, telling the provider everything is fine — that event never comes back.
| Situation | Correct response | Effect |
|---|---|---|
| Event received and accepted (even if ignored by the business) | 200 | Provider closes the delivery |
| Invalid signature, malformed payload | 400 / 401 | Permanent error — retrying won't help |
| Transient failure (database down, queue full) | 500 | Provider retries with backoff — exactly what you want |
Fix: classify your handler's failures as permanent or transient and return the code that produces the retry behavior you need.
It pays to automate the classification: validation exceptions become 4xx, infrastructure exceptions become 5xx, and the "not sure" case becomes 5xx — when in doubt, receiving the event again beats losing it forever.
7. Fragile payload parsing
Three variations of the same mistake. Overly strict validation that rejects the payload when the provider adds a new field — and providers add fields without notice; to them, that's not a breaking change. Assuming the content type, when some providers send application/x-www-form-urlencoded or JSON inside a form field. And the most treacherous: verifying the signature against re-serialized JSON — JSON.stringify(JSON.parse(body)) changes whitespace and key order, and the signature never matches again.
Fix: always compute the HMAC over the raw body (the request's original bytes, before any parsing — details in our security guide), ignore unknown fields instead of rejecting them, and read the Content-Type instead of assuming it.
8. No HTTPS, or a secret in the URL
Webhook payloads carry customer emails, amounts, payment identifiers — traffic that must not travel in plain text. And the common shortcut of "putting a token in the URL" (/webhooks?secret=abc123) as the only authentication is fragile: URLs show up in proxy logs, monitoring tools and configuration history, and the secret leaks along with them.
Fix: HTTPS is mandatory (serious providers won't even accept an http:// URL), keep the secret out of the URL, and guarantee authenticity with an HMAC signature, rotating the secret periodically.
9. Not monitoring deliveries
Webhooks fail silently: there is no user staring at a screen to complain about the error. The typical scenario is discovering days later that orders stopped syncing — and some providers (GitHub and Stripe, for example) automatically disable endpoints that fail persistently, turning a temporary bug into a permanent outage.
Fix: log every delivery (event, status returned, latency), alert when the failure rate climbs, and have a replay/reprocessing path to catch up after an incident. A webhook inspection tool helps you see what is actually arriving when the numbers don't add up.
The minimum worth logging per delivery: event id, type, arrival time, the status you returned and processing time. With those five fields you can answer the questions that matter during an incident — "did the event arrive?", "did we accept it?", "when did the failure rate start climbing?" — without depending on the provider's dashboard.
10. No test environment
Registering the production URL and "seeing if it works" is the shortest path to processing real events with broken code — and to polluting production data with tests. The reason is almost always the same: webhooks are annoying to test locally, so testing gets skipped.
Fix: use the provider's own test events (Stripe CLI, Mercado Pago's simulator, GitHub's redelivery), inspect the real payload with an inspection endpoint before writing the parser, and develop against your localhost with a tunnel or forwarding — the full walkthrough is in How to test webhooks on localhost.
Summary
- Respond fast and process in a queue; the provider's timeout won't wait for your email to be sent.
- Verify the signature over the raw body and treat the endpoint as the public door it is.
- Idempotency is not optional: the same event will arrive more than once.
- Event order and content are hints, not truth — confirm the state through the API.
- The right status code for each failure, per-delivery logs and replay will save the integration in its first incident.