Webhooks fail silently. When your API call fails, you get an error in your own logs. When a webhook fails, the error lands in someone else's delivery logs (Stripe's, GitHub's, your payment provider's) and you find out days later when the data that should have arrived didn't. Testing webhook endpoints properly means verifying the whole delivery path: reachability, TLS, response behaviour, signature verification, and timeouts.
This guide covers testing an endpoint you've built to receive webhooks. The same checks apply if you're debugging why a sender's deliveries to you keep failing.
How webhook delivery actually works
A webhook is just an HTTP POST the sender makes to your URL when an event happens. The sender expects:
- The URL resolves and accepts a TLS connection.
- Your endpoint responds with a 2xx status code, usually within a few seconds.
- Anything else (4xx, 5xx, timeout, connection error) counts as a failed delivery, and most senders retry with backoff before eventually disabling the endpoint.
Every failure mode below maps to one of those three expectations.
Step 1. Verify the URL is publicly reachable
The sender's servers are on the public internet. localhost, private IPs, and hosts behind a corporate firewall are unreachable to them, no matter how well they work in your tests.
Check DNS resolves from outside your network with the DNS Lookup tool, then confirm port 443 is open with the Port Checker. If you're developing locally, tunnel your dev server to a public URL (ngrok, Cloudflare Tunnel), but remember the tunnel URL changes between sessions, and a stale tunnel URL registered with the sender is one of the most common "webhooks stopped working" causes.
Step 2. Check the TLS certificate
Most senders require HTTPS and verify the certificate strictly, more strictly than browsers in one specific way: many webhook senders don't fetch missing intermediate certificates, so a chain that looks fine in Chrome can fail server-to-server.
Run the endpoint through the SSL Certificate Checker and confirm three things: the certificate isn't expired, the hostname matches, and the full chain including intermediates is served. Or from the terminal:
echo | openssl s_client -connect hooks.example.com:443 -servername hooks.example.com 2>/dev/null | openssl x509 -noout -dates
An expired cert turns every delivery into a connection error overnight. If your webhook endpoint matters, put it behind an SSL expiry monitor so renewal failures page you before they break deliveries.
Step 3. Send a manual POST
Don't wait for the sender to fire a real event. Replicate the delivery with curl:
curl -i -X POST https://hooks.example.com/webhooks/stripe \ -H "Content-Type: application/json" \ -d '{"id":"evt_test_001","type":"ping","data":{}}'
Read the response:
- 200/201/204: good. Most senders accept any 2xx.
- 301/302: your endpoint redirects (often HTTP→HTTPS or a trailing-slash rewrite). Many senders do not follow redirects and treat them as failures. Register the final URL, exactly.
- 401/403: your auth middleware is rejecting the request. Webhook routes usually need to be excluded from session/cookie auth, since the sender authenticates via signature, not login.
- 404: wrong path. Compare the registered URL character-for-character with your route definition.
- 405: your route only accepts GET. Webhooks are POSTs.
- 413: payload too large. Some senders attach full resource objects; check your body-size limit.
- 500: your handler is throwing. Read your application logs with this exact payload.
The HTTP Header Checker is useful here too: it shows whether your endpoint sits behind a redirect or a challenge page (a CDN bot-protection rule that serves an interstitial to non-browser clients will block webhook senders cold).
Step 4. Test signature verification
Production webhook handlers must verify signatures; an unauthenticated endpoint that accepts any POST will eventually process a forged event. The standard scheme is HMAC: the sender computes an HMAC (typically SHA-256) of the raw body using a shared secret and puts it in a header (Stripe-Signature, X-Hub-Signature-256, etc.).
To test it manually, compute the signature yourself and send it:
BODY='{"id":"evt_test_001","type":"ping","data":{}}' SECRET='whsec_your_test_secret' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') curl -i -X POST https://hooks.example.com/webhooks/github \ -H "Content-Type: application/json" \ -H "X-Hub-Signature-256: sha256=$SIG" \ -d "$BODY"
(Adjust the header name and format to your sender's scheme; Stripe, for example, signs timestamp.body and includes the timestamp in the header.)
Then test the negative case: send the same request with a wrong signature and confirm you get a 401/403, not a 200. A handler that accepts bad signatures is worse than one with no verification, because it looks secure.
Two implementation bugs cause the vast majority of "signature verification keeps failing" reports:
- Verifying against parsed-then-reserialized JSON. The HMAC is computed over the raw bytes of the body. If your framework parses the JSON and you re-stringify it for verification, key ordering and whitespace differences change the bytes and the signature never matches. Capture the raw body before parsing.
- Wrong secret for the environment. Test-mode and live-mode events are usually signed with different secrets. Check which one your config holds.
Step 5. Check your response time
Senders enforce delivery timeouts, commonly in the 5-30 second range depending on the provider. If your handler does slow work inline (database writes, calling other APIs, sending emails), deliveries will intermittently time out under load, get retried, and you'll process duplicates.
The fix is structural: acknowledge first, process later. Validate the signature, persist the raw event to a queue or table, return 200, and do the real work asynchronously. Your response time drops to milliseconds regardless of what processing involves.
Time your endpoint to confirm:
curl -s -o /dev/null -w "status=%{http_code} total=%{time_total}s\n" \ -X POST https://hooks.example.com/webhooks/stripe \ -H "Content-Type: application/json" -d '{"type":"ping"}'
Anything consistently over a second deserves the async treatment.
Step 6. Handle retries idempotently
Because timeouts and transient failures trigger retries, every webhook will eventually be delivered more than once. Use the event ID from the payload as an idempotency key: before processing, check whether you've seen that ID; if so, return 200 and skip. Returning 200 for duplicates matters: a 4xx makes the sender keep retrying the event you've already handled.
Step 7. Read the sender's delivery logs
Most webhook providers expose a delivery log showing each attempt, the response code they received, the response time, and the retry schedule. When deliveries fail, this log tells you what their servers saw, which is more authoritative than what your tests from your own network show. GitHub's "Recent Deliveries" tab even lets you redeliver a specific event, the fastest possible test loop once the endpoint is live.
TL;DR
- Confirm the URL is publicly reachable: DNS Lookup plus Port Checker on 443.
- Validate the TLS chain with the SSL Certificate Checker; senders are stricter than browsers about intermediates.
- POST manually with curl; demand a 2xx, and remember redirects count as failures.
- Test signatures with a computed HMAC, and confirm a bad signature gets rejected.
- Verify against the raw body bytes, never re-serialized JSON.
- Respond fast: ack, queue, process async.
- Expect duplicates; deduplicate on event ID and return 200 for repeats.
Related
- HTTP Header Checker - spot redirects and bot-protection pages blocking sender requests
- How to check open ports - confirming reachability at the TCP layer
- How to diagnose website downtime - the same layered method, applied to full outages
