How to test Stripe webhooks: practical guide with examples
Integrating with Stripe is almost mandatory for anyone working with online payments. And the most critical part of the integration? Webhooks. They are the mechanism that notifies your system when a payment is confirmed, a subscription is canceled, or a dispute is opened.
In this guide, we’ll show you how to test Stripe webhooks efficiently, from the development environment to production.
Why are Stripe webhooks important?
Stripe uses webhooks to notify your application about asynchronous events. Without them, you don’t know if:
- A Pix or boleto payment was confirmed
- A subscription was renewed or canceled
- A card was declined after the attempt
- A dispute (chargeback) was opened
Golden rule: never rely solely on the checkout return. The webhook is the source of truth for payment status.
Anatomy of a Stripe webhook
When an event happens, Stripe sends a POST to your URL with this structure:
{
"id": "evt_1ABC123",
"object": "event",
"type": "payment_intent.succeeded",
"created": 1713456000,
"data": {
"object": {
"id": "pi_3XYZ789",
"amount": 4900,
"currency": "brl",
"status": "succeeded",
"payment_method": "pm_card_visa",
"metadata": {
"order_id": "12345"
}
}
}
}
Important headers
| Header | Value |
|---|---|
Content-Type | application/json |
Stripe-Signature | HMAC signature for validation |
User-Agent | Stripe/1.0 (...) |
Step 1: Configure the endpoint in Stripe
In the Stripe Dashboard, add an endpoint:
- Go to Developers → Webhooks
- Click Add endpoint
- Paste your endpoint URL
- Select the events you want to receive
Which events to listen to?
For payments, the most common are:
payment_intent.succeeded— payment confirmedpayment_intent.payment_failed— payment failedcharge.refunded— refund processedcustomer.subscription.updated— subscription changedcustomer.subscription.deleted— subscription canceledinvoice.payment_succeeded— invoice paidcheckout.session.completed— checkout completed
Step 2: The localhost problem
In development, your server runs on localhost:3000 or similar. Stripe can’t access that URL. You have three options:
Option A: Stripe CLI (official)
stripe listen --forward-to localhost:3000/webhook
Works well, but:
- Requires installing the CLI
- The URL changes every session
- No visual request history
Option B: ngrok
ngrok http 3000
Creates a public tunnel, but:
- Temporary URL (changes on every restart on the free plan)
- Need to update the endpoint in Stripe every time
- No webhook replay
Option C: HookScope (recommended)
- Create an endpoint on HookScope
- Copy the generated permanent URL
- Paste it in Stripe as the webhook endpoint
- Receive webhooks on localhost via dashboard (auto-forward) or CLI:
# Install the CLI
dotnet tool install -g HookScope.Cli
# Authenticate and listen
hookscope login --api-key YOUR_KEY
hookscope listen my-endpoint --to http://localhost:3000/webhook
Advantages:
- Permanent URL — configure once, never change again
- CLI for local debug — receive webhooks on localhost without tunnels via
hookscope listen - Replay with 1 click — re-send any webhook without re-triggering the event
- Real-time visualization — see headers, body, and status instantly
- Automatic forwarding — forwards to your localhost in the background
- Complete history — access webhooks from days ago
Step 3: Validate the webhook signature
Never process a webhook without validating the signature. Anyone could send fake requests to your URL.
Node.js / Express
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["stripe-signature"];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error("Invalid signature:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Process the event
switch (event.type) {
case "payment_intent.succeeded":
const paymentIntent = event.data.object;
console.log("Payment confirmed:", paymentIntent.id);
// Update order in database
break;
case "payment_intent.payment_failed":
console.log("Payment failed");
break;
}
res.json({ received: true });
});
C# / ASP.NET Core
[HttpPost("webhook")]
public async Task<IActionResult> HandleWebhook()
{
var json = await new StreamReader(HttpContext.Request.Body).ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"];
try
{
var stripeEvent = EventUtility.ConstructEvent(
json, signature, _webhookSecret
);
switch (stripeEvent.Type)
{
case EventTypes.PaymentIntentSucceeded:
var paymentIntent = stripeEvent.Data.Object as PaymentIntent;
_logger.LogInformation("Payment {Id} confirmed", paymentIntent?.Id);
break;
}
return Ok(new { received = true });
}
catch (StripeException e)
{
_logger.LogError("Invalid signature: {Message}", e.Message);
return BadRequest();
}
}
Tip: when using HookScope with forwarding (via dashboard or CLI), the webhook arrives at your localhost with all original headers, including the
Stripe-Signature. Validation works normally.
Step 4: Test with the Stripe CLI
The Stripe CLI lets you trigger test events:
# Trigger a specific event
stripe trigger payment_intent.succeeded
# Trigger with custom data
stripe trigger checkout.session.completed \
--override checkout_session:metadata.order_id=12345
These events are sent to the configured endpoint — including your HookScope endpoint.
Step 5: Debug when something goes wrong
The most common problems with Stripe webhooks:
The webhook doesn’t arrive
- Check if the URL is correct and accessible
- Confirm the right events are selected in the Dashboard
- Check the attempt log in Stripe: Developers → Webhooks → your endpoint → Recent events
400/500 error in the response
- Check if you’re using
express.raw()(Node.js) or reading the body as a string (not JSON parsed) - Signature validation needs the raw body, not parsed
Invalid signature
- Check if the
STRIPE_WEBHOOK_SECRETis correct (starts withwhsec_) - In development, use the Stripe CLI secret, not the Dashboard one
- Confirm the body is not being modified by middleware
Replay to re-test
With HookScope, when you identify the bug and fix your code, just click Replay on the failed webhook. It’s re-sent to your server with the original payload — no need to re-trigger the event in Stripe.
Production checklist
Before going to production with Stripe webhooks:
- Endpoint configured with production HTTPS URL
- Signature (
Stripe-Signature) validated on every request - Idempotent processing (check
event.idbefore processing) - Quick
200return (process in background if necessary) - Failure monitoring (alerts if webhook returns error)
- Retry handling — Stripe makes up to 3 attempts on failure
- Complete logging of received and processed events
Conclusion
Testing Stripe webhooks doesn’t have to be painful. With the right tools, you can:
- Capture webhooks with a permanent URL
- Inspect every detail of the payload
- Re-send webhooks to test fixes
- Forward automatically to your local environment (via dashboard or CLI)
Create your free HookScope account and simplify your Stripe integration testing.