Stripe Webhook Testing: Local Development Guide
Stripe Webhook Testing: Local Development Guide
Stripe webhooks notify your application about events—successful payments, failed charges, subscription changes, disputes. Getting them right matters. A missed webhook can mean unfulfilled orders or confused customers.
Testing webhooks locally used to be painful. Now Stripe has decent tooling. Here's how to use it effectively.
Setting Up the Stripe CLI
The Stripe CLI is your primary tool for local webhook testing. Install it first.
macOS:
brew install stripe/stripe-cli/stripe
Windows:
scoop install stripe
Linux: Download from Stripe's releases page or use their apt/yum repos.
After installation, authenticate:
stripe login
This opens a browser to link your Stripe account. You'll need to redo this periodically—the session expires.
Forwarding Webhooks to Localhost
Start the listener:
stripe listen --forward-to localhost:3000/webhooks/stripe
You'll see output like:
Ready! Your webhook signing secret is whsec_abc123...
Save that signing secret. You'll need it for signature verification. It's different from your dashboard webhook secret—this one is specific to the CLI session.
Set it in your environment:
export STRIPE_WEBHOOK_SECRET=whsec_abc123...
Now Stripe CLI intercepts webhooks and forwards them to your local server.
Triggering Test Events
You can trigger specific events without making real payments:
stripe trigger payment_intent.succeeded
Common events to test:
# Payment flow
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
stripe trigger charge.refunded
# Subscriptions
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.paid
stripe trigger invoice.payment_failed
# Checkout
stripe trigger checkout.session.completed
Each trigger sends a realistic webhook payload to your forwarded endpoint.
Signature Verification
Always verify webhook signatures. See Stripe's signature verification guide for full details. Here's the pattern in different languages:
Node.js:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.log(`Webhook signature verification failed.`, err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
// Handle successful payment
break;
// ... other cases
}
res.json({received: true});
});
Go:
func handleWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error reading body", http.StatusBadRequest)
return
}
event, err := webhook.ConstructEvent(
payload,
r.Header.Get("Stripe-Signature"),
os.Getenv("STRIPE_WEBHOOK_SECRET"),
)
if err != nil {
http.Error(w, "Signature verification failed", http.StatusBadRequest)
return
}
switch event.Type {
case "payment_intent.succeeded":
// Handle
}
w.WriteHeader(http.StatusOK)
}
Python:
import stripe
from flask import Flask, request
@app.route('/webhooks/stripe', methods=['POST'])
def stripe_webhook():
payload = request.get_data()
sig_header = request.headers.get('Stripe-Signature')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, os.environ['STRIPE_WEBHOOK_SECRET']
)
except ValueError as e:
return 'Invalid payload', 400
except stripe.error.SignatureVerificationError as e:
return 'Invalid signature', 400
if event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
# Handle
return '', 200
The Raw Body Problem
A common gotcha: signature verification requires the raw request body. If your framework parses JSON automatically before your handler runs, verification will fail.
In Express, use express.raw():
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), handler);
In other frameworks, ensure you're reading the raw body before any JSON parsing middleware touches it. The Stripe docs on signature errors cover this in detail.
Debugging Failed Webhooks
When things go wrong:
1. Check the CLI output
The Stripe CLI shows request/response details:
2026-01-25 10:23:45 --> payment_intent.succeeded [evt_123...]
2026-01-25 10:23:45 <-- [400] POST http://localhost:3000/webhooks/stripe
A 400 response usually means signature verification failed.
2. Check your logs
Add logging before and after signature verification to see where it fails.
3. Verify the secret
The CLI provides a session-specific secret. Make sure you're using the right one. The secret from your Stripe dashboard won't work with CLI-forwarded webhooks.
4. Check for body parsing issues
If you're getting "No signatures found matching the expected signature for payload" errors, you likely have a body parsing problem.
Testing Without the CLI
Sometimes you want to test with captured real payloads. Options:
Replay from Stripe Dashboard: In your Stripe dashboard, go to Developers > Webhooks > your endpoint. You can resend any recent webhook delivery.
Use a webhook capture service: Services like ThunderHooks act as a webhook inbox. Point Stripe at your ThunderHooks URL once, and every webhook is captured—even when your laptop is closed.
When you're ready to debug:
- Open your dashboard, see the exact payload Stripe sent
- Start ngrok or your preferred tunnel
- Replay the webhook to your tunnel URL
- Fix your bug, replay again—same webhook, no need to trigger another Stripe event
This is especially useful for debugging production issues. Capture real production webhooks, then replay them against your local code until you find the bug.
Mock the webhook: For unit tests, don't call Stripe at all. Mock the webhook payload and test your handler logic directly.
Production Checklist
Before going live:
- Webhook endpoint is HTTPS (required by Stripe)
- Signature verification is enabled with production secret
- Handler returns 2xx quickly (< 30 seconds)
- Idempotency handling for duplicate deliveries
- Error logging captures event ID for debugging
- Retry handling for temporary failures
- Critical events have monitoring/alerting
Common Events to Handle
At minimum, most Stripe integrations need:
| Event | When | Action |
|---|---|---|
checkout.session.completed |
Customer finishes checkout | Fulfill order |
payment_intent.succeeded |
Payment completes | Record payment, send receipt |
payment_intent.payment_failed |
Payment fails | Notify customer, retry logic |
customer.subscription.created |
New subscription | Provision access |
customer.subscription.deleted |
Subscription canceled | Revoke access |
invoice.payment_failed |
Subscription payment fails | Dunning flow |
See Stripe's webhook events documentation for the full list.
Conclusion
The Stripe CLI makes local webhook testing manageable. Set up forwarding, trigger events, verify signatures work, and test your handler logic.
The key is testing the unhappy paths too—failed payments, disputed charges, expired cards. Those are where webhook handling bugs tend to hide.