Free and open source · MIT

Webhook Mock Sender

Webhook Mock Sender builds a realistic Stripe, GitHub or Shopify event, signs it with your webhook secret using that provider's own scheme, and POSTs it to any URL, localhost included. It also sends what a provider never will on demand: a wrong signature, a replayed request, a duplicate delivery. That is how you find out whether your handler refuses what it should.

Webhook Mock Sender showing a signed Stripe request and the delivery result from a local endpoint

What Webhook Mock Sender does

It removes the provider from the loop. No dashboard, no test mode, no tunnel. Just a signed request arriving at your endpoint, and a report of what your endpoint said back.

Feature What it means
Valid signatures Stripe-Signature, X-Hub-Signature-256 and X-Shopify-Hmac-Sha256 computed over the exact bytes that are sent. The Stripe signature is tested against the official stripe library.
28 event templates Payments, subscriptions and invoices for Stripe; push, pull requests, issues and releases for GitHub; orders, products and customers for Shopify. Fresh IDs and timestamps on every run.
Provider headers X-GitHub-Event, X-GitHub-Delivery, X-Shopify-Topic, X-Shopify-Webhook-Id, the provider's user agent and the rest.
Failure scenarios --invalid-signature, --timestamp-offset for a replay, --repeat for a duplicate, and --expect to assert the answer.
Editable payloads Change a field with --set, or sign and send your own JSON with --payload-file.
Web form Pick an event, edit the body, watch the signature header update as you type, send, and read the response.

Quick start

Run it without installing anything, using uv:

Terminal
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
Output
Stripe payment_intent.succeeded → http://localhost:3000/webhooks/stripe
✓ Delivery: HTTP 200 in 12 ms
  response    {"received": true}

Or through Docker, where host.docker.internal is your machine as seen from inside the container:

Terminal
docker run --rm --add-host host.docker.internal:host-gateway \
  -e STRIPE_WEBHOOK_SECRET ghcr.io/webhooker-eu/webhook-mock-sender \
  send stripe payment_intent.succeeded http://host.docker.internal:3000/webhooks/stripe

The secret comes from --secret, or from the variable you probably already have: STRIPE_WEBHOOK_SECRET, GITHUB_WEBHOOK_SECRET, SHOPIFY_WEBHOOK_SECRET, with WEBHOOK_MOCK_SECRET as a fallback for any provider.

Testing the unhappy paths

A handler that accepts a correctly signed event proves very little. The interesting tests are the ones a provider will not run for you:

Terminal
# 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

With --expect, the exit code is 0 only when your endpoint answers with that status, so these lines work as assertions in CI. If the endpoint accepts a request with a wrong signature, the tool says so in as many words: your endpoint does not verify signatures.

Changing the payload

Terminal
# Change fields of the template
webhook-mock-sender send stripe invoice.paid "$URL" \
  --set data.object.total=9900 \
  --set data.object.customer_email=ada@example.com

# Sign and send your own body, byte for byte
webhook-mock-sender send github label "$URL" --payload-file event.json

# Look at the signed request without sending it
webhook-mock-sender send stripe charge.refunded --dry-run
webhook-mock-sender send shopify orders/paid "$URL" --curl

How each provider signs a webhook

All three use HMAC-SHA256 with a shared secret over the raw request body. What differs is the header, the encoding, and whether a timestamp is part of the signed string.

Provider Header Value Signed content Replay protection
Stripe Stripe-Signature t=<unix time>,v1=<hex digest> <unix time>.<raw body> Yes: the time is signed, and libraries reject anything older than 5 minutes
GitHub X-Hub-Signature-256 sha256=<hex digest> raw body No, deduplicate on X-GitHub-Delivery
Shopify X-Shopify-Hmac-Sha256 <base64 digest> raw body No, deduplicate on X-Shopify-Webhook-Id

Which events are included

Provider Mock events
Stripe payment_intent.succeeded, payment_intent.payment_failed, charge.succeeded, charge.refunded, checkout.session.completed, customer.created, customer.subscription.created / updated / deleted, invoice.paid, invoice.payment_failed
GitHub ping, push, pull_request, issues, issue_comment, release, star, workflow_run
Shopify orders/create, orders/paid, orders/fulfilled, orders/cancelled, products/create, products/update, customers/create, checkouts/create, app/uninstalled

The payloads follow the providers' documented shapes but are trimmed: they exercise your handler's plumbing, not every field of the provider's API reference. For anything not listed, pass the real JSON with --payload-file.

Inside a test suite

The same request can be built in Python and handed straight to your framework's test client, so a signature test runs without a network:

Python
from webhook_mock_sender import build_mock_request, get_provider

mock_request = build_mock_request(
    get_provider("stripe"),
    "payment_intent.succeeded",
    "whsec_test_secret",
    overrides=["data.object.amount=5000"],
)

# In a test, without a network
response = test_client.post(
    "/webhooks/stripe",
    content=mock_request.body,
    headers=mock_request.headers,
)

From testing to production traffic

Mock events tell you your handler is correct. They say nothing about what happens when your service is redeploying at the moment a real payment lands, or when a provider gives up after three failed attempts. That is a delivery problem, not a verification one.

Webhooker takes it over: one EU-hosted ingest URL per source, the provider's signature verified before the payload is accepted, every event stored durably, six delivery attempts over about five hours, a dead-letter queue and one-click replay. The free plan covers 10,000 events a month.

Frequently asked questions

How do I test a Stripe webhook locally without the Stripe CLI?

Set STRIPE_WEBHOOK_SECRET to the same value your app verifies against and run webhook-mock-sender send stripe payment_intent.succeeded http://localhost:3000/webhooks/stripe. The Stripe-Signature header is computed the way Stripe computes it, so stripe.Webhook.construct_event accepts the request. No Stripe account, dashboard or login is involved.

Why does my endpoint reject a correctly signed request?

Almost always because the signature is checked against a re-serialized body. Verify the raw bytes of the request, before any JSON middleware parses them. The other usual causes are the wrong secret (test versus live, or the stripe listen secret versus the dashboard one) and a proxy that rewrites the body on the way in.

How do I check that my handler refuses a forged webhook?

Send one. --invalid-signature signs with a wrong secret, --timestamp-offset -600 imitates a replayed request, and --repeat 2 sends the same delivery twice. Pair any of them with --expect 400 (or 401) and the exit code is 0 only when your endpoint answered with that status, which makes it a test rather than a manual check.

Can I send an event that is not in the list?

Yes. Save the JSON to a file and pass it with --payload-file; the event name you give is used for X-GitHub-Event or X-Shopify-Topic, and the body is signed exactly as it is. You can also start from a template and change single fields with --set data.object.amount=5000.

Is a mock event the same as a real one?

The headers and the signature scheme are identical. The payload is a trimmed, realistic sample with random IDs, so the objects it refers to do not exist at the provider: a handler that calls the provider's API back will get a 404 there. For plumbing (verification, idempotency, status codes, retries) that makes no difference.

How do I receive real webhooks on localhost?

That is the opposite direction and this tool does not do it. Use a request bin such as Webhook Tester to look at what a provider sends, or the Webhooker gateway to receive, store and replay production webhooks through one ingest URL.

Further reading

More open-source tools

Everything we publish lives in the webhooker-eu GitHub organisation, and the full list with a short description of each is on the open-source tools page.

When the testing is done, the events still have to arrive.

Webhooker is the hosted side of the same problem: one EU-hosted ingest URL for any provider, signatures verified before a payload is accepted, every event stored, retried and replayable. Free for 10,000 events a month.