In an integration test with a chain partner the argument is always the same: they say the event was sent, you say nothing arrived, and the meeting is about whose log is right. Webhooks are that argument in HTTP form. The sender decides when it fires, it goes to a public URL your laptop cannot see, and when it fails, nobody is told.
This post covers the tools that make the request visible, the tunnels that bring it to your machine, the signature check that keeps the endpoint honest, and the six failures that account for most of the debugging time.
Catch the request somewhere you can see it
An inspector gives you a throwaway URL and shows every request that hits it: headers, body, query string, timing. Point the sender at it before you write a line of handler code, and you get the real payload instead of the one in the documentation.
webhook.site is the one I reach for first. No account, a unique URL in one click, and the free tier keeps the last 500 requests. That covers almost every test.
Hookdeck is built for the production side: inspection plus queueing, retries and delivery logs. The free console is a fine inspector on its own, and the paid tier is what you move to when the endpoint has to survive a burst.
Svix Play is a clean, open-source inspector with no signup. Same job as webhook.site, different interface.
The routine:
- Create the URL in the inspector.
- Paste it into the sender's webhook settings (Stripe, GitHub, Shopify, your chain partner's test environment).
- Trigger the event: a test payment, a push, an order.
- Read the captured request. Save the body as a fixture.
- Compare the fields you got with the fields the documentation promised. They differ more than you would expect.
Run the saved body through the JSON formatter so the nesting is readable, and if the sender publishes a schema, check the fixture against it in the JSON schema validator. A fixture that passes the schema on day one is the baseline for every later discussion about who changed the payload.
For the other direction, the API request builder composes the request (method, headers, body) and gives you the curl command to fire it at your endpoint. That is how you test the handler before the sender exists.

Bring it to your laptop
The sender needs a public URL. Your development server is on localhost:3000. A tunnel connects the two.
ngrok is the standard. ngrok http 3000 prints a public https address that forwards to your port. The free plan includes one static domain, so the URL no longer changes on every restart the way it used to. The local dashboard at localhost:4040 shows every request and lets you replay it, which is an inspector for free.
Cloudflare Tunnel does the same with cloudflared tunnel --url http://localhost:3000, no account needed for a quick tunnel. A named tunnel on a domain you own is the stable option for a shared test environment.
localtunnel is the open-source fallback: lt --port 3000. Simple, and it drops the connection more than the other two.
The vendor CLIs skip the tunnel entirely. stripe listen --forward-to localhost:3000/api/webhooks streams Stripe events straight to your handler and prints the signing secret to use. gh webhook forward (an extension, installed with gh extension install cli/gh-webhook) does the same for GitHub. When the sender has one of these, use it. The URL never changes and there is nothing to paste into a dashboard.
Workflow: start the dev server, start the tunnel or the CLI, paste the URL into the sender's settings if you had to, trigger the event, and step through the handler in a real debugger. That last step is the whole point. It is the one thing the inspector cannot give you.
The sender needs a public URL.
Verify the signature, or anyone can post to your endpoint
A webhook endpoint is a public URL that runs code when it receives a POST. Without verification, anyone who finds the URL can trigger that code with a payload of their choosing.
Check the signature first, before parsing anything. Stripe, GitHub, Shopify and most others sign the body with a shared secret and put the result in a header. Your handler recomputes it and rejects the request on a mismatch.
Stripe, in Node:
`javascript
const sig = request.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
request.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Process the verified event
} catch (err) {
return response.status(400).send('Signature verification failed');
}
`
The verification needs the raw request body, byte for byte. A server library that parses JSON before your code runs (Express with a global body parser is the classic case) changes the bytes and every signature fails. Register the raw parser on the webhook route only.
Reject old timestamps. The signature includes a timestamp, and Stripe's library rejects anything older than five minutes by default. Keep that. It stops a captured request from being replayed next week.
HTTPS only. An http endpoint hands the payload and the signature to anyone on the path.
Idempotency. Every sender retries on failure, so every handler receives duplicates. Store the event id and skip an id you have seen. Without this, a retried payment.succeeded event sends the customer two confirmation emails and creates two orders.
An IP allowlist is a fourth layer where the sender publishes its ranges. It is not a replacement for the signature, because the ranges change and the allowlist does not.
The six failures that eat the debugging time
Nothing arrives. Open the sender's delivery log first, not your own. Every serious sender shows each attempt, the response code and the retry history. The usual causes: a wrong URL, a firewall, DNS that does not resolve from outside, or an earlier error that made the sender disable the endpoint.
It arrives and nothing happens. Log at the first line of the handler, before verification. If the log line appears and the event does not get processed, the signature check is failing, and nine times out of ten that is the raw-body problem above.
Timeouts. Senders wait between ten and thirty seconds for a response. A handler that writes to the database, calls another API and sends an email inside that window will time out under load. Return 200 as soon as the event is verified and stored, and do the work from a queue.
The payload changed. A string became an object, a field was renamed, a new required field appeared. Pin the API version where the sender offers one (Stripe does), keep the fixture from day one, and diff the two when something breaks. The schema validator turns "it looks different" into a list of exactly which fields.
Missing event types. Most senders make you enable each event type. If subscription.updated never arrives, check the box before you check the code.
Retry storms. Return a 500, the sender retries. The retry fails, it retries again, with backoff if you are lucky. Under a real outage that is a queue of thousands of deliveries arriving the moment you come back up. Return 200 for events you have stored and will process later, and keep 5xx for the cases where you actually want the retry.

FAQ
How do I test webhooks in a pipeline?
With the fixtures you saved from the inspector. The pipeline posts each fixture to the handler with curl, signed with the test secret, and asserts on the result. That tests your code without the external service. For an end-to-end run, use the sender's test mode to fire real events at a deployed test environment, and read the sender's delivery log as part of the assertion.
Do I need a webhook management service?
For one integration, no. Signature check, idempotency and a queue cover it. For an application receiving events from five providers, Hookdeck, Svix or Convoy give you the retries, the logs and the replay button in one place, and that is cheaper than building it.
What if events arrive out of order?
They will. subscription.updated can land before subscription.created. Do not derive state from the order of arrival. Use the timestamp in the event, or better, treat the webhook as a notification and fetch the current state from the sender's API before acting on it.
Which response code should the endpoint return?
200 or 204 when you have accepted the event. 400 for a bad signature or an unparseable body, because retrying will not fix those. 5xx only when your side is actually down and you want the retry. The HTTP status codes reference is the quick check when a sender's log shows a code you did not expect.
Images by Pexels
CSS Container Queries: Let the Component Ask Its Parent, Not the Window
How container queries work, the container-type trap that collapses an element to nothing, the range syntax that replaces min and max, container units for type that follows the box, and the split between container and media queries that keeps both readable.
The PWA Manifest, in the Order the Browser Checks It
Why the install prompt does not show even though your manifest.json looks fine: the five required fields, the icon rules behind most failures, display modes, and where Chrome tells you what is wrong.
Web Scraping Law and Ethics in 2026: Where the Lines Are
What you may legally scrape in the US and EU in 2026, why robots.txt matters without being law, where GDPR draws the line, and a request rate that avoids blocks.
AI Code Explainer: When to Trust the Output
Learn how an AI code explainer and language converter work. Understand their strengths for learning and porting code, and when not to trust the output.
