Simulating Stripe and GitHub Webhooks Locally
The local webhook handler rejects every event with No signatures found matching the expected signature for payload from the Stripe SDK, or GitHub deliveries fail with X-Hub-Signature-256 mismatch; meanwhile a colleague "fixed" the same problem by commenting out signature verification when NODE_ENV=development, which is how unverified webhooks reach production. Webhooks are the half of an integration where the provider calls you, and a laptop is not reachable from the internet. This page delivers correctly signed Stripe and GitHub events to a local stack — relayed from the provider or generated from fixtures — without ever disabling verification, as part of mocking external APIs and third-party services.
The goal is that the handler code that runs locally, including verification, is byte-for-byte the code that runs in production.
Diagnostic
Send a known event and inspect what the handler received and which secret it used:
#!/usr/bin/env bash
set -euo pipefail
echo "STRIPE_WEBHOOK_SECRET prefix: ${STRIPE_WEBHOOK_SECRET:0:6}"
curl -s -o /tmp/resp.txt -w 'status %{http_code}\n' -X POST http://localhost:8080/webhooks/stripe \
-H 'content-type: application/json' -H 'Stripe-Signature: t=1726650000,v1=deadbeef' \
--data @mocks/webhooks/stripe/invoice.payment_failed.json
cat /tmp/resp.txt; echo
docker compose logs api --since 1m | grep -iE 'signature|webhook' | tail -3
grep -rn "NODE_ENV.*development" src/webhooks | head -3 || true
Expected bad output:
STRIPE_WEBHOOK_SECRET prefix: whsec_
status 400
Webhook Error: No signatures found matching the expected signature for payload
api-1 | webhook: signature verification failed (stripe)
src/webhooks/stripe.ts:12: if (process.env.NODE_ENV === 'development') return handle(JSON.parse(req.body));
The test signature is invalid (as expected), but the code already contains a development bypass — the pattern this page replaces.
Root cause
Stripe signs timestamp.raw_body with HMAC-SHA256 using the endpoint's signing secret; GitHub signs the raw body with the webhook secret and sends it as X-Hub-Signature-256. Verification fails for three reasons. The secret is wrong: every Stripe endpoint and every stripe listen session has its own whsec_ secret, so the production or dashboard secret does not verify events forwarded by the CLI. The body changed: frameworks that parse JSON before the handler (Express's json() middleware, for example) hand over an object, and re-serialising it produces different bytes than were signed. Or the timestamp is stale: Stripe's SDK rejects signatures older than five minutes by default, which breaks replays of saved events signed earlier. Disabling verification in development hides all three and leaves the production path untested.
The raw-body problem is the one most often misdiagnosed, because it depends on middleware order rather than on the webhook code itself. An application can work for months with a raw-body route, then break when someone moves a global express.json() or a body-logging middleware above it during an unrelated refactor. The signature error that follows looks like a secret problem, so the first reaction is to rotate secrets, which changes nothing. Checking whether req.body is a Buffer at the top of the handler turns that hour of confusion into a one-line log message.
Resolution
- Keep the raw body for webhook routes so verification sees exactly the bytes that were signed:
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_API_KEY);
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
res.json({ received: true, type: event.type });
});
app.use(express.json());
app.listen(8080);
Register the raw-body route before any global JSON middleware, and delete the development bypass.
- Relay real test-mode events with the Stripe CLI, and use the secret it prints:
#!/usr/bin/env bash
set -euo pipefail
stripe login
stripe listen --forward-to localhost:8080/webhooks/stripe --print-secret > /tmp/whsec
export STRIPE_WEBHOOK_SECRET="$(cat /tmp/whsec)"
docker compose up -d --force-recreate api
stripe listen --forward-to localhost:8080/webhooks/stripe &
stripe trigger invoice.payment_failed
- Sign stored fixtures locally for offline work, CI and rare events, using the same scheme the provider uses. For GitHub:
#!/usr/bin/env bash
set -euo pipefail
secret="${GITHUB_WEBHOOK_SECRET:?set GITHUB_WEBHOOK_SECRET}"
payload_file=mocks/webhooks/github/pull_request.opened.json
sig="sha256=$(openssl dgst -sha256 -hmac "$secret" < "$payload_file" | awk '{print $2}')"
curl -fsS -X POST http://localhost:8080/webhooks/github \
-H 'content-type: application/json' \
-H 'X-GitHub-Event: pull_request' \
-H "X-GitHub-Delivery: $(uuidgen)" \
-H "X-Hub-Signature-256: $sig" \
--data-binary @"$payload_file" -w '\nstatus %{http_code}\n'
--data-binary sends the file byte-for-byte; plain --data strips newlines and breaks the signature. For Stripe, sign timestamp.payload with the current timestamp, as shown in the external API mocking topic.
- Test idempotency by sending each event twice. Providers retry and occasionally deliver duplicates; handlers must record processed event IDs and skip repeats.
Expected output
$ stripe trigger invoice.payment_failed
Setting up fixture for: invoice.payment_failed
Trigger succeeded! Check dashboard for event details.
2026-09-18 10:41:07 --> invoice.payment_failed [evt_1Q…]
2026-09-18 10:41:07 <-- [200] POST http://localhost:8080/webhooks/stripe
$ ./scripts/send-github-webhook.sh
{"received":true,"type":"pull_request"}
status 200
Relayed and locally signed events both verify and reach the handler, with no environment-specific branch in the code.
Sending the same GitHub fixture a second time should return 200 again without repeating side effects — no second email, no duplicate database row. If it does repeat them, the idempotency check is missing, which is worth fixing now: providers do redeliver, and a duplicate invoice.paid that credits an account twice is a far more expensive way to discover it.
Prevention
Ban verification bypasses with a CI grep over webhook handlers for
NODE_ENV,DEBUGorskipVerificationconditions.Keep a fixture per handled event type under
mocks/webhooks/<provider>/, and a CI test that sends each one (signed with a test secret) and expects a 2xx.Use development secrets only. The local webhook secret lives in the git-ignored env file and differs from production; see managing local secrets without committing to git.
Platform caveats
WSL2: run
stripe listenwhere it can reach the application's published port; from WSL,localhost:8080reaches a Compose service published on Windows through Docker Desktop.
macOS:
uuidgenis available by default; on minimal Linux images usecat /proc/sys/kernel/random/uuidinstead.
Cloud workspaces: for providers without a relay CLI, expose the webhook port publicly for the duration of the test, as described in forwarding ports from a cloud workspace, then make it private again.
Rollback
The raw-body change is safe to keep. To stop relaying, stop the CLI; stored fixtures remain available:
#!/usr/bin/env bash
set -euo pipefail
pkill -f 'stripe listen --forward-to localhost:8080' || true
unset STRIPE_WEBHOOK_SECRET
Frequently Asked Questions
Why does Stripe say no signatures match even with the right secret?
Usually the body was parsed and re-serialised before verification. Use a raw-body parser on the webhook route so the SDK sees the exact bytes Stripe signed. Also check that you are using the secret printed by stripe listen, not the dashboard endpoint's secret.
Can I replay an old saved Stripe event?
Not with its original signature, which is older than the five-minute tolerance. Re-sign it with the current timestamp and your local secret, or trigger a fresh event with stripe trigger.
Is it acceptable to skip signature verification in development?
No. It leaves the verification path untested and tends to leak into production configuration. Sign events locally instead; it costs one HMAC per event.
How do we test events that are hard to trigger, like disputes?
Store a fixture for each such event type, taken from the provider's documentation and scrubbed, and sign it locally. stripe trigger also supports many rare events in test mode.