Back to blog

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

HeaderValue
Content-Typeapplication/json
Stripe-SignatureHMAC signature for validation
User-AgentStripe/1.0 (...)

Step 1: Configure the endpoint in Stripe

In the Stripe Dashboard, add an endpoint:

  1. Go to Developers → Webhooks
  2. Click Add endpoint
  3. Paste your endpoint URL
  4. Select the events you want to receive

Which events to listen to?

For payments, the most common are:

  • payment_intent.succeeded — payment confirmed
  • payment_intent.payment_failed — payment failed
  • charge.refunded — refund processed
  • customer.subscription.updated — subscription changed
  • customer.subscription.deleted — subscription canceled
  • invoice.payment_succeeded — invoice paid
  • checkout.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
  1. Create an endpoint on HookScope
  2. Copy the generated permanent URL
  3. Paste it in Stripe as the webhook endpoint
  4. 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_SECRET is correct (starts with whsec_)
  • 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.id before processing)
  • Quick 200 return (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:

  1. Capture webhooks with a permanent URL
  2. Inspect every detail of the payload
  3. Re-send webhooks to test fixes
  4. Forward automatically to your local environment (via dashboard or CLI)

Create your free HookScope account and simplify your Stripe integration testing.