To test a webhook, you need to answer five questions in order: what does the provider actually send, does your handler accept a correctly signed request and reject a forged one, does it behave when the same event arrives twice or late, does it answer fast enough, and is it still receiving events in production. Each question has its own tool. A request bin answers the first, unit tests and a signed mock sender answer the next three, and delivery logs with alerts answer the last.
Most webhook bugs that reach production fall through the gaps between those layers. The handler passed a unit test with a hand-written JSON body that no provider ever sends. It verified signatures locally but not behind the production proxy. It processed a payment twice because nobody tested a retry. This guide walks through each layer with commands you can run today.
The five layers at a glance
| Layer | What it proves | Tool |
|---|---|---|
| 1. Capture | What the provider really sends: headers, body, encoding | An online webhook tester or a self-hosted request bin |
| 2. Unit tests | Signature check, parsing and business logic on real bytes | Your test framework plus captured fixtures |
| 3. Local end-to-end | The running handler accepts real and synthetic events | Provider CLI, a tunnel or relay, a signed mock sender |
| 4. Failure rehearsal | Retries, duplicates, stale timestamps, slow answers | A mock sender with --repeat and --expect, a bin that answers 500 |
| 5. Production checks | Events still arrive and get processed | Provider delivery logs, your own logs, alerts, replay |
Layers 2 and 4 belong in CI. Layer 5 runs forever.
1. Capture one real request first
Before you write a test, look at a real delivery. Provider documentation shows the JSON body, but it rarely shows the full request: the exact header names and casing, the content type, whether the body is pretty-printed or compact, and which fields are null rather than missing. Shopify’s sample payload and a real orders/create from a store with discounts and multiple shipping lines are not the same document.
The fastest way to get one is a throwaway URL:
- Open the online webhook tester and create a URL. It lives for 30 minutes and keeps requests in memory only.
- Paste the URL into the provider’s webhook settings.
- Trigger an event: GitHub sends a
pingthe moment you save a new webhook, Stripe can fire any event type withstripe trigger payment_intent.succeeded, and Shopify’s CLI sends one withshopify app webhook trigger --topic orders/create --address <url>. - Read the request. Paste your signing secret into the tester and it checks the Stripe, GitHub, Shopify or Slack signature in the browser, showing the signed string and both digests.
Then save the raw body to a file, byte for byte, along with its headers. That file becomes your first test fixture. Do not copy the pretty-printed JSON from the viewer: re-formatting changes the bytes, and the signature no longer matches.
If the payload carries customer data, or you want to watch the provider retry, run the self-hosted webhook tester in one Docker container on your own server instead. It stores requests in SQLite and can answer with any status code, which comes back in layer 4. For a wider comparison of request bins, see webhook.site alternatives.
2. Unit-test the handler against raw bytes
The most important webhook unit test is the signature check, and the most common way to get it wrong is to test it on a parsed object. Providers sign the exact bytes they send. If your test builds a dictionary, serializes it and signs the result, it proves your code agrees with itself, not with Stripe or GitHub. Sign the captured fixture instead.
Here is the shape of it for a GitHub handler, using pytest and FastAPI’s test client. The same four cases apply to any framework:
import hashlib
import hmac
import json
from pathlib import Path
TEST_SECRET = b"test-webhook-secret"
PUSH_EVENT_BODY = Path("tests/fixtures/github_push.json").read_bytes()
def github_signature(raw_body: bytes, secret: bytes = TEST_SECRET) -> str:
digest = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def github_headers(raw_body: bytes, delivery_id: str = "delivery-1") -> dict:
return {
"Content-Type": "application/json",
"X-GitHub-Event": "push",
"X-GitHub-Delivery": delivery_id,
"X-Hub-Signature-256": github_signature(raw_body),
}
def test_signed_delivery_is_accepted(client):
response = client.post("/webhooks/github", content=PUSH_EVENT_BODY,
headers=github_headers(PUSH_EVENT_BODY))
assert response.status_code == 200
def test_tampered_body_is_rejected(client):
headers = github_headers(PUSH_EVENT_BODY)
tampered_body = PUSH_EVENT_BODY.replace(b'"main"', b'"evil"')
response = client.post("/webhooks/github", content=tampered_body, headers=headers)
assert response.status_code == 401
def test_reserialized_body_is_rejected(client):
headers = github_headers(PUSH_EVENT_BODY)
reserialized_body = json.dumps(json.loads(PUSH_EVENT_BODY)).encode()
response = client.post("/webhooks/github", content=reserialized_body, headers=headers)
assert response.status_code == 401
def test_duplicate_delivery_is_processed_once(client, processed_events):
headers = github_headers(PUSH_EVENT_BODY, delivery_id="delivery-42")
client.post("/webhooks/github", content=PUSH_EVENT_BODY, headers=headers)
client.post("/webhooks/github", content=PUSH_EVENT_BODY, headers=headers)
assert processed_events.count("delivery-42") == 1
The third test looks redundant, but it guards a real regression. If someone later adds a body-parsing middleware in front of the route, the handler starts hashing re-encoded JSON, and every production delivery fails while the happy-path test keeps passing because it, too, goes through the middleware. A test that sends deliberately re-serialized bytes and expects a rejection proves the check really runs on the wire bytes. The signature verification failed guide lists the other ways the raw body gets altered.
Use a test-only secret in fixtures. Never commit a live signing secret to make a test pass.
For providers that sign a timestamp, such as Stripe with t= in the Stripe-Signature header, add one more case: a request signed ten minutes ago must be rejected. Stripe’s libraries accept five minutes by default. Why that window exists is covered in replay attacks and timestamp tolerance.
The duplicate test belongs here as well. Every major provider delivers at least once, so the same event will arrive twice sooner or later. Key the deduplication on the provider’s delivery or event ID, as described in webhook idempotency keys.
3. Send events to the handler on localhost
Unit tests call your handler in-process. The next step is to hit the running app over HTTP, the way a provider will. There are two kinds of traffic you can send it: synthetic events that you sign yourself, and real events from the provider.
Synthetic, signed events
The quickest test needs nothing but curl and openssl. This sends a correctly signed GitHub ping to a local handler:
BODY='{"zen":"Keep it logically awesome.","hook_id":1}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" | sed 's/^.* //')
curl -i http://localhost:3000/webhooks/github \
-H 'Content-Type: application/json' \
-H 'X-GitHub-Event: ping' \
-H "X-GitHub-Delivery: $(uuidgen)" \
-H "X-Hub-Signature-256: sha256=$SIGNATURE" \
--data-raw "$BODY"
It works, but every provider signs differently: Stripe puts a timestamp into the signed string, Shopify sends a Base64 digest instead of hex. Hand-rolling each scheme gets old quickly. Webhook Mock Sender is our open-source CLI that does it for Stripe, GitHub and Shopify from realistic event templates:
export STRIPE_WEBHOOK_SECRET='whsec_...' # the secret your endpoint verifies against
uvx --from git+https://github.com/webhooker-eu/webhook-mock-sender \
webhook-mock-sender send stripe payment_intent.succeeded http://localhost:3000/webhooks/stripe
Change single fields with --set data.object.amount=5000, or sign and send the fixture you captured in step 1 with --payload-file. Add --dry-run or --curl to see the signed request without sending it.
Real events from the provider
Synthetic events prove your handler follows the documented contract. Real events prove the contract is what you think it is. The provider cannot reach localhost, so something has to carry the request across:
| Approach | How it works | Trade-off |
|---|---|---|
| Provider CLI | stripe listen --forward-to localhost:3000/webhooks/stripe | Stripe only. It prints its own whsec_ secret, different from the dashboard one, so load that secret locally. |
| Tunnel | ngrok or Cloudflare Tunnel exposes a local port on a public URL | The URL often changes per session, so you re-register it at the provider. Events sent while the tunnel is down are lost. |
| Relay | The provider posts to a stable hosted URL; a CLI on your machine pulls each event and replays it to localhost | No open port, and events are stored while you are offline. Needs an account with the relay. |
The ngrok alternatives for webhooks post compares these in more depth. Whichever you choose, check that it forwards the body byte for byte and passes signature headers through unchanged, or your handler’s own check will fail on forwarded requests and send you hunting for a bug that is not in your code.
4. Rehearse the failure paths
A handler that returns 200 for a valid event has passed the easy half. Providers judge your endpoint by how it fails. These are the cases to try before production tries them for you.
Forged and stale requests. A wrong signature and an old timestamp must both be refused. With Webhook Mock Sender, --expect turns each into a pass-or-fail check:
# A wrong signature must be refused
webhook-mock-sender send stripe invoice.paid "$URL" --invalid-signature --expect 400
# Signed 10 minutes ago: Stripe's libraries allow 5, so this is a replay
webhook-mock-sender send stripe invoice.paid "$URL" --timestamp-offset -600 --expect 400
# The same delivery twice: the handler should process it once
webhook-mock-sender send github push "$URL" --repeat 2
After --repeat 2, look at the side effect, not the status code. Both deliveries should get a 2xx, and the order, email or credit should exist once.
Slow answers. Providers give up on slow endpoints and count the attempt as failed. GitHub waits 10 seconds, Shopify 5. Add a deliberate delay to the handler in a test environment and watch the provider mark the delivery as timed out, then retry it. The fix is structural: verify the signature, store the event, answer 2xx, and do the real work in a background job. The Postgres job queue with SKIP LOCKED post shows one way to build that queue.
Errors and retries. Point the provider at a self-hosted webhook tester configured to answer 500 and watch what happens: how many retries, at what intervals, and whether the provider eventually disables the endpoint. Shopify retries 8 times over 4 hours and can then remove the subscription; Stripe retries for up to three days in live mode. Knowing the schedule tells you how long an outage you can absorb without replaying anything. Webhook retries and exponential backoff covers the patterns behind those schedules.
Order. Send customer.subscription.updated before customer.subscription.created and see what your handler does. Webhook order is not guaranteed, and the usual fix is to fetch current state from the provider’s API rather than trusting the event sequence. More in ordered webhook delivery.
5. Run webhook tests in CI
Layers 2 and 4 are cheap enough to run on every pull request. The unit tests run with the rest of your suite. The over-the-wire checks need the app running, which most pipelines already do for integration tests. With --expect, Webhook Mock Sender exits with 0 only when the endpoint answers with the expected status, so a failed check fails the build:
- uses: astral-sh/setup-uv@v6
- name: Start the app
run: |
npm start &
npx wait-on http://localhost:3000/health
- name: Webhook contract checks
env:
STRIPE_WEBHOOK_SECRET: whsec_ci_only_secret
URL: http://localhost:3000/webhooks/stripe
run: |
MOCK="uvx --from git+https://github.com/webhooker-eu/webhook-mock-sender webhook-mock-sender"
$MOCK send stripe payment_intent.succeeded "$URL"
$MOCK send stripe payment_intent.succeeded "$URL" --invalid-signature --expect 400
$MOCK send stripe payment_intent.succeeded "$URL" --timestamp-offset -600 --expect 400
The app under test must load the same CI-only secret. Without a flag, the exit code is 0 on any 2xx, so the first line is the happy path.
6. Check that webhooks still work in production
“How do I check if a webhook is working?” is the question people search after something has already gone quiet. Answer it before that happens.
- Read the provider’s delivery log. GitHub keeps recent deliveries per webhook with the request, your response and a Redeliver button. Stripe and Shopify show delivery attempts and failures in their dashboards. This is the provider’s view, and it is the one that decides retries.
- Log every delivery on your side with the provider’s delivery or event ID, the signature result, your response status and the processing outcome. When the provider says it sent something and your logs disagree, the gap is in between: DNS, a proxy, a WAF rule, a certificate.
- Alert on two things: a rising share of non-2xx answers, and silence. If an integration normally receives events every hour, no events for six hours is an incident, even though no request failed.
- Know how to replay. After an outage, you need to redeliver the events you missed, from the provider’s dashboard or from your own stored copies. Test that path once before you need it. Webhook retries and replay goes through what to replay and in what order.
Where Webhooker fits
Webhooker puts a gateway in front of your handler, which covers several of these layers without code in your app. Each source has its own ingest URL that checks the provider’s signature on the raw bytes as they arrive and stores every event, whether or not your service is up. The Webhooker CLI, whk, forwards those events to localhost with the original method, headers and body, so your local signature check passes, and replays any stored event or a whole range of failed deliveries after an outage. whk tail prints one line per event as it arrives, with the signature result, which is the quickest answer to “is the provider actually sending anything?”. In production, every delivery attempt is kept with its response, and alerts go out by email or Telegram when deliveries run out of retries or a destination turns unhealthy.
The free plan covers 10,000 events a month, and the CLI, Webhook Mock Sender and both webhook testers are free and open source.
Testing checklist
- One real request captured from each provider and saved as a byte-exact fixture
- Unit tests: valid signature accepted, tampered body rejected, re-serialized body rejected, duplicate processed once
- Timestamped schemes: a request older than the tolerance window rejected
- Handler answers within a second or two and defers the work to a queue
- Provider retry schedule known, and a retry observed at least once
- Out-of-order events handled without corrupting state
- Signed happy path and rejection paths running in CI
- Delivery logs keyed by provider ID, alerts on failures and on silence
- Replay rehearsed once, before the first outage
Frequently asked questions
How do I test a webhook?
Start by catching one real request: create a temporary URL with a webhook tester, register it with the provider and trigger an event, so you see the exact headers and body. Save that raw body as a test fixture. Then unit-test your handler with it, checking that a correctly signed request is accepted and a tampered or re-serialized one is rejected. Finally, send signed events to the running app with a mock sender, and rehearse duplicates, stale timestamps and slow answers before you go live.
How do I test a webhook locally?
Your laptop has no public address, so the provider cannot reach it directly. You have three options: a provider CLI such as stripe listen --forward-to localhost:3000/webhooks/stripe, a tunnel such as ngrok that exposes a local port, or a relay where the provider posts to a stable hosted URL and a CLI on your machine replays each event to localhost. For events you generate yourself, a mock sender signs a realistic payload and posts it straight to localhost with no public URL at all.
How do I check if a webhook is working?
Look at the provider’s delivery log first: GitHub, Stripe and Shopify all show each delivery attempt with your endpoint’s response. Compare it with your own logs, keyed by the provider’s delivery ID. A delivery the provider sent but you never logged points to DNS, a proxy, a firewall or TLS; a delivery you logged with an error points to your handler. In production, also alert on silence, since a webhook that stops arriving fails without a single error.
Can I test webhooks without exposing localhost to the internet?
Yes, in two ways. A mock sender runs on your machine and posts signed synthetic events to localhost, so nothing is exposed. For real provider events, a relay keeps the public URL on the relay’s side: your machine only opens an outbound connection to pull events and replay them locally, so no inbound port is opened and no tunnel is needed.
Why does my webhook test pass locally but fail in production?
The usual causes are a different signing secret in production, such as a live-mode Stripe secret where you tested with the one printed by stripe listen, and something in the production request path that changes the body: a proxy, a CDN, a WAF or a body-parsing middleware. A slow handler is the third: locally it answers in milliseconds, in production it waits on a database and exceeds the provider’s timeout. Compare the received byte length with Content-Length on a failing request, then check the secret and the response time.