Stage one: see the payload
Before writing a line of handler code, point the webhook at a request bin, a throwaway URL that captures whatever arrives (webhook.site and friends exist exactly for this). Fire a test event and read the actual payload: field names, types, timestamp format. Real payloads beat documentation every time.
Small details break verification more often than handler logic does. Chong, on our team, chased a webhook verification failure on our own domain setup flow and found the culprit was a leading www on the domain. It was the www tripping the check, and verification came good the moment it was dropped. A request bin shows you that class of bug in one glance.
Stages two and three: localhost, then trust
While building the handler, tunnel production events to your machine: an ngrok-style tunnel gives your localhost a public URL, so real events hit your debugger. Send yourself a message, watch delivered fire, force a bounce with a bad address, watch that fire too.
Before production, test the paranoid paths: replay the same event twice and confirm nothing double-processes, send a payload with a wrong signature and confirm your handler rejects it. Nitrosend signs every event with HMAC-SHA256; a handler that skips verification is an open door with logging.
Test against events, not API responses. George Hartley, our CEO, once trusted a live response that read sent: 0, failed: 2, status: completed and shipped the campaign again as a resend. It was the third copy. The response told him the request finished. Only the event stream tells you what actually happened. It is also why we mirror provider open webhooks into one counter, email:open_total, so the count holds true no matter which writer records the open.
Coverage gaps hide in the registration step, not the handler. Nick, on our team, found ours in review: app/uninstalled and the order and customer webhooks were not being registered at runtime under a legacy install flow, so uninstall did not auto-deactivate and nothing synced in real time until we caught it. List the events you think you subscribed to, then read back what the platform says is actually registered.
Test data shapes what you can test, too. QA on one of our re-engagement flows surfaced a 404 on the unsubscribe link, and the cause was the test path itself: the legacy test-send used recipients that weren't real contacts, so there was no contact to bind an unsubscribe token to. The fix was routing test sends through the real transactional endpoint with real contacts, so every link works because nothing is mocked. If your test path and your production path differ, you're testing the test path.
Full parameters and responses live in the REST API docs and the API reference.
Every trap in this section, the untested signature, the unregistered event, the mocked test path, exists because a person wrote the handler by hand and missed a case. The MCP path skips the handler entirely. Claude, Cursor, or Codex can read the event stream directly and act on delivered, bounced, or complained without you writing or testing a line of webhook code. I think that's the real fix, not another test case, and it's why Nitrosend is a listed app in the ChatGPT app marketplace rather than just a payload to test against.
Go deeper
The signature check and the rest of the hardening: how to secure webhooks. The mechanism itself: webhooks explained. Testing the receipt is one layer of deliverability testing, and the events it delivers are what surface bounce rate.
FAQ
How do I test a webhook?
Three stages: capture a real payload with a request bin, tunnel events to localhost while you build the handler, then verify edge cases: duplicate events processed once, bad signatures rejected.
How do I test webhooks locally?
Use a tunnel (like ngrok) to expose localhost on a public URL, register that URL as your endpoint, and trigger real test events so your local handler sees production-shaped payloads.