A CLI and a local web form that send fake Stripe, GitHub and Shopify webhook events to your endpoint — signed exactly the way the provider signs them, so your signature check passes.
webhooker.eu · Quick start · CLI · Web form · Signatures · More tools
Webhook Mock Sender is a free, open-source tool by Webhooker for testing a
webhook endpoint without touching the real provider. It builds a realistic Stripe, GitHub or Shopify
event, signs it with your webhook secret using the provider's own scheme, and POSTs it to any URL —
localhost included. No account, no tunnel, no test-mode dashboard.
It also sends the requests a provider never will on demand: a wrong signature, a replayed old request, a duplicate delivery. That is how you check that your handler refuses what it should.
Everything runs on your own machine, so the signing secret never goes to a third-party website.
| Feature | Description |
|---|---|
| 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 -600 (a replay) and --repeat 2 (a duplicate delivery), with --expect 401 to assert the answer in CI. |
| Editable payloads | Change one field with --set data.object.amount=5000, or sign and send your own JSON with --payload-file. |
| Web form | Pick an event, edit the body, watch the signed headers update, send, and read what your endpoint answered. |
| Export | Print the signed request, or get it as a curl command. |
| Readable results | Status, response body, timing, and a hint for the usual mistakes: parsed body instead of raw body, redirects, Docker's localhost. |
| Script friendly | --json output, meaningful exit codes, secrets from environment variables. |
Run it without installing, using uv:
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/stripeStripe payment_intent.succeeded → http://localhost:3000/webhooks/stripe
✓ Delivery: HTTP 200 in 12 ms
response {"received": true}
Or with Docker, nothing else installed (host.docker.internal is your machine as seen from the
container):
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/stripeOr install it as a command:
uv tool install git+https://github.com/webhooker-eu/webhook-mock-sender
# or
pipx install git+https://github.com/webhooker-eu/webhook-mock-sender
webhook-mock-sender send github push http://localhost:3000/webhooks/github --secret 'my-secret'webhook-mock-sender send PROVIDER EVENT [URL] [options]
webhook-mock-sender list [PROVIDER]
webhook-mock-sender serve
The endpoint URL is the last argument or the WEBHOOK_MOCK_URL variable. The signing secret comes
from --secret, or from the variable you most likely already have:
| Provider | Secret variable | Where the secret comes from |
|---|---|---|
stripe |
STRIPE_WEBHOOK_SECRET |
whsec_… from the Stripe dashboard or stripe listen |
github |
GITHUB_WEBHOOK_SECRET |
The Secret field of the repository or app webhook |
shopify |
SHOPIFY_WEBHOOK_SECRET, then SHOPIFY_API_SECRET |
The app's client secret |
| any | WEBHOOK_MOCK_SECRET |
Fallback for all providers |
Prefer variables: command-line arguments end up in shell history.
# List the events
webhook-mock-sender list
webhook-mock-sender list shopify
# Send an event
webhook-mock-sender send stripe checkout.session.completed http://localhost:3000/webhooks/stripe
webhook-mock-sender send github pull_request http://localhost:3000/webhooks/github
webhook-mock-sender send shopify orders/create http://localhost:3000/webhooks/shopify
# 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
# Send your own body, signed byte for byte
webhook-mock-sender send github label "$URL" --payload-file event.json
cat event.json | webhook-mock-sender send github label "$URL" --payload-file -
# See the signed request without sending it
webhook-mock-sender send stripe charge.refunded --dry-run
webhook-mock-sender send shopify orders/paid "$URL" --curl# A wrong signature must be refused
webhook-mock-sender send stripe invoice.paid "$URL" --invalid-signature --expect 400
# A request signed 10 minutes ago is a replay: Stripe's libraries allow 5 minutes
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 2With --expect, the exit code is 0 only when the endpoint answers with that status. If your
endpoint accepts the bad request, the tool says so:
✗ Delivery: expected HTTP 400, got HTTP 200
hint Your endpoint accepted a request with a wrong signature: it does not verify signatures.
| Option | Description |
|---|---|
--secret SECRET |
Signing secret. Defaults to the provider's variable, then WEBHOOK_MOCK_SECRET. |
--set PATH=VALUE |
Change a field of the template, like data.object.amount=5000. The value is parsed as JSON when possible. Repeatable. |
--payload-file PATH |
Send this JSON body instead of a template; - reads stdin. Any event name is accepted. |
--header 'NAME: VALUE' |
Add or replace a header, like X-Shopify-Shop-Domain: my-store.myshopify.com. Repeatable. |
--invalid-signature |
Sign with a wrong secret. |
--timestamp-offset SECONDS |
Shift the signed time; -600 imitates a replay of an old request (Stripe). |
--repeat COUNT |
Send the same delivery, with the same ID, COUNT times. |
--expect STATUS |
Succeed only if the endpoint answers with this status code. |
--dry-run |
Print the signed request and exit without sending. |
--curl |
Print the request as a curl command and exit. |
--json |
Print the request and the deliveries as JSON. |
--timeout SECONDS |
Request timeout, 15 by default. |
--insecure |
Do not verify the endpoint's TLS certificate. |
| Code | Meaning |
|---|---|
0 |
The endpoint answered 2xx, or the status given with --expect. |
1 |
The endpoint answered something else, or could not be reached. |
2 |
Invalid input: an unknown provider or event, no secret, a bad URL, an unreadable file. |
This makes the tool usable in CI, against the app started by the pipeline:
webhook-mock-sender send stripe payment_intent.succeeded http://localhost:3000/webhooks/stripe
webhook-mock-sender send stripe payment_intent.succeeded http://localhost:3000/webhooks/stripe \
--invalid-signature --expect 400| Provider | Mock events |
|---|---|
| Stripe | payment_intent.succeeded, payment_intent.payment_failed, charge.succeeded, charge.refunded, checkout.session.completed, customer.created, customer.subscription.created, customer.subscription.updated, customer.subscription.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 are for testing your
handler's plumbing, not a replacement for the provider's API reference. For an event that is not
listed, pass the real JSON with --payload-file.
All three use HMAC-SHA256 with a shared secret over the raw request body. The differences are the header, the encoding and whether a timestamp is signed.
| 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, libraries reject requests 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 |
The check your endpoint needs, in Python:
import base64, hashlib, hmac
def is_valid_github_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_value)
def is_valid_shopify_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
return hmac.compare_digest(base64.b64encode(digest).decode(), header_value)For Stripe, use stripe.Webhook.construct_event(raw_body, signature_header, secret).
webhook-mock-sender serve --open # http://127.0.0.1:8080Pick a provider and an event, enter your endpoint and the secret, and press Send webhook. The right side shows the request as it will be sent: the signature header is recomputed as you edit the body. The curl and CLI tabs give you the same request for a script.
The form keeps the provider, event and URL in the browser's local storage. The secret is stored only if you tick Remember the secret in this browser.
docker run --rm -p 127.0.0.1:8080:8080 --add-host host.docker.internal:host-gateway \
ghcr.io/webhooker-eu/webhook-mock-sender # http://localhost:8080Inside a container localhost is the container itself. To reach an app running on your machine,
use http://host.docker.internal:3000/... as the endpoint. The image is built for linux/amd64
and linux/arm64, and works as the CLI too:
docker run --rm --add-host host.docker.internal:host-gateway -e STRIPE_WEBHOOK_SECRET \
ghcr.io/webhooker-eu/webhook-mock-sender \
send stripe invoice.paid http://host.docker.internal:3000/webhooks/stripePrefer to build it yourself?
git clone https://github.com/webhooker-eu/webhook-mock-sender.git
cd webhook-mock-sender
docker compose up -d --build # http://localhost:8080Set PORT in a .env file to publish a different host port.
The form talks to a small JSON API; interactive docs are served at /docs.
| Method | Path | Body | Description |
|---|---|---|---|
GET |
/api/providers |
Providers, their signature schemes and events. | |
POST |
/api/template |
{"provider", "event"} |
A fresh sample body. |
POST |
/api/preview |
{"provider", "event", "secret", "body"?, …} |
The signed request and a curl command, without sending. |
POST |
/api/send |
the same, plus {"target_url", "repeat"?} |
Sign, send and report each delivery. |
GET |
/healthz |
Health check. |
Handy in a test suite: build a signed request and feed it to your framework's test client.
from webhook_mock_sender import WebhookSender, 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)
# Or over HTTP
with WebhookSender() as sender:
result = sender.send("http://localhost:3000/webhooks/stripe", mock_request)
print(result.ok, result.status_code, result.response_body)How do I test a Stripe webhook locally without the Stripe CLI?
Set STRIPE_WEBHOOK_SECRET to the same value your app uses and run
webhook-mock-sender send stripe payment_intent.succeeded http://localhost:3000/webhooks/stripe.
The Stripe-Signature header is valid, so stripe.Webhook.construct_event accepts the request. No
Stripe account or login is needed.
Why does my endpoint answer 400 "signature verification failed" to 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 common causes are a different
secret (test-mode versus live, or the stripe listen secret versus the dashboard one) and a proxy
that changes the body.
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 as is.
Is a mock event the same as a real one? The headers and the signature scheme are the same. The payload is a trimmed, realistic sample with random IDs, so objects it refers to do not exist at the provider: a handler that calls the provider's API back will get a 404 there.
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 Webhooker to receive, store and replay production webhooks through one
ingest URL like https://app.webhooker.eu/in/{token}.
.
├── src/webhook_mock_sender/
│ ├── cli.py # Argument parsing and terminal output
│ ├── mock_request.py # Builds the body and signed headers, payload overrides, curl export
│ ├── sender.py # Delivery over HTTP and explanations of the answers
│ ├── providers/ # Stripe, GitHub, Shopify: event templates, signing, verification
│ ├── web.py # FastAPI app behind the web form
│ └── static/ # Single-file UI (vanilla JS, no build step) and bundled fonts
├── tests/ # pytest suite, no network access needed
├── .github/workflows/ # CI, GHCR image and PyPI publishing
├── Dockerfile # python-slim + uv, runs as a non-root user
├── docker-compose.yml
├── pyproject.toml # Dependencies, managed with uv
└── uv.lock
- Python 3.12+ and uv
uv sync # install dependencies
uv run webhook-mock-sender send stripe invoice.paid --secret test --dry-run
uv run webhook-mock-sender serve # http://127.0.0.1:8080
uv run pytest # run the testsTo add a provider, subclass Provider in src/webhook_mock_sender/providers/, implement
build_headers and verify_signature, and register it in providers/__init__.py.
- A webhook secret is a credential. Keep it in an environment variable or a secret store, not in code or shell history. Use test-mode secrets with this tool, not production ones.
- The tool sends requests to whatever URL you give it, including private addresses — that is its
purpose.
servelistens on127.0.0.1by default and has no authentication. If you expose it on a network, put it behind your reverse proxy's auth, or anyone who can reach it can make requests from your machine. - The API only accepts
application/json, so a web page on another origin cannot drive the local server from your browser. - Send mock events only to endpoints you own or are allowed to test.
Webhooker is an EU-hosted inbound webhook gateway: one ingest URL for any provider, with signature verification, durable storage, retries and replay — so you never lose an event. This tool is one of the free, open-source utilities we publish for people who work with webhooks.
- Website: webhooker.eu
- More open-source tools: github.com/webhooker-eu
- Need to inspect incoming webhooks instead of sending them? Try webhook-tester, our self-hosted request bin.
- Testing a Discord webhook? Try discord-webhook-tester.
The web form bundles the Unbounded and
Figtree typefaces under the SIL Open Font License; the
license texts are in src/webhook_mock_sender/static/fonts/. This project is not affiliated with
Stripe, GitHub or Shopify; their names are used only to describe the webhook formats.
