How to Test Stripe Webhooks with HookCap
By HookCap Team
How to Test Stripe Webhooks with HookCap
Stripe webhooks are the backbone of any payment integration: subscriptions activate, invoices go overdue, payment methods expire. Getting your webhook handlers right matters. Getting them wrong means silent failures in production.
The challenge is that Stripe needs a public URL to deliver events to — but during development your server is on localhost. You either set up a tunnel and manage it every session, or you capture webhooks in a persistent cloud endpoint where you can inspect, debug, and replay them at will.
This guide walks through using HookCap as your Stripe webhook debugger.
Why Stripe Webhooks Are Hard to Test
Three specific things make Stripe webhook testing more painful than other integrations:
1. Event volume. A single checkout triggers a cascade: payment_intent.created, charge.succeeded, checkout.session.completed, customer.created, sometimes invoice.created and invoice.paid. You need to see all of them in order to understand your handler’s behavior.
2. Signature verification. Every Stripe webhook is signed with HMAC-SHA256. Your handler must verify the Stripe-Signature header before processing. This requires the raw request body — not parsed JSON — and the correct signing secret. One misconfiguration silently rejects every event.
3. Retry behavior. If your handler returns a non-2xx status (or times out), Stripe retries with exponential backoff over 72 hours. Understanding how retries interact with your business logic requires observing the full sequence, not just the first delivery.
HookCap addresses all three: it captures every delivery in a persistent log, shows the raw headers your handler will receive, and lets you replay any event against your local or staging server.
Setting Up HookCap for Stripe Webhooks
1. Create an endpoint in HookCap
Sign in at hookcap.dev and create a new endpoint. You get a permanent URL like:
https://hook.hookcap.dev/ep_a1b2c3d4e5f6
This URL does not expire between sessions. Configure it once in Stripe, and it keeps receiving events.
2. Register the endpoint with Stripe
In the Stripe Dashboard, go to Developers → Webhooks → Add endpoint.
Paste your HookCap endpoint URL and select the events you want to capture. For a typical subscription integration:
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
Or select all events to capture everything during initial development.
You can also register via the Stripe API:
curl https://api.stripe.com/v1/webhook_endpoints \
-u sk_test_YOUR_KEY: \
-d url="https://hook.hookcap.dev/ep_a1b2c3d4e5f6" \
-d "enabled_events[]"="checkout.session.completed" \
-d "enabled_events[]"="invoice.paid" \
-d "enabled_events[]"="invoice.payment_failed" \
-d "enabled_events[]"="customer.subscription.updated" \
-d "enabled_events[]"="customer.subscription.deleted"
Stripe will show a signing secret for this endpoint (whsec_...). Save it — you will use it when replaying events to your local server.
3. Trigger your first event
Run through your checkout flow in Stripe test mode. Use test card 4242 4242 4242 4242 for a successful payment. Within seconds, the event will appear in your HookCap dashboard.
Capturing and Inspecting Stripe Events
Once events start flowing, HookCap shows you everything Stripe sends.
What to look at in each delivery
Each captured webhook includes:
- Headers — including
Stripe-Signature,Content-Type, and the Stripe event ID - Raw body — the exact JSON payload Stripe sent
- Delivery timestamp and response latency
For Stripe debugging, the Stripe-Signature header is particularly important. It looks like:
t=1711900800,v1=abc123...,v0=def456...
The t= value is the timestamp Stripe signed (used to prevent replay attacks). The v1= value is the HMAC-SHA256 signature. Your handler must recompute this and compare — verifying against the wrong secret or a parsed body causes silent failures.
Key events and what they contain
checkout.session.completed — fires when a customer completes a Checkout session. The payload includes:
{
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_test_...",
"customer": "cus_...",
"subscription": "sub_...",
"payment_status": "paid",
"amount_total": 1200,
"currency": "usd",
"metadata": {}
}
}
}
Use data.object.subscription to link the Stripe subscription to your database record.
invoice.paid — fires on every successful subscription renewal. Your handler should update the user’s plan status and next billing date.
invoice.payment_failed — fires when a renewal attempt fails. Your handler should trigger dunning emails and mark the subscription at risk.
customer.subscription.deleted — fires when a subscription is fully cancelled (not when the user requests cancellation — that’s subscription.updated with cancel_at_period_end: true).
Comparing payloads across events
One of the most useful things you can do in HookCap is compare multiple deliveries side by side. If you have a sequence like invoice.created → invoice.paid → customer.subscription.updated, you can open each payload and trace the same subscription ID through all three events. This is much faster than digging through terminal logs.
Replaying Webhooks During Development
The replay feature is where HookCap’s workflow advantage over the Stripe CLI becomes clear.
Typical debugging loop
- Write your webhook handler
- A real Stripe event fires during your test
- Your handler has a bug — returns 500 or processes the event incorrectly
- Fix the bug
- Replay the exact same payload to your local server
In HookCap, replay sends the original payload (with the original headers) to any URL you specify. Point it at http://localhost:3000/webhooks/stripe to test against your local server.
Signature verification during replay
When you replay against your local server, you need to handle signature verification. Two options:
Option A: Disable verification in development. Add a flag to skip stripe.webhooks.constructEvent() when NODE_ENV === 'development':
if (process.env.NODE_ENV !== 'development') {
const sig = req.headers['stripe-signature'];
stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
}
Option B: Re-sign with your local secret. HookCap can re-sign the payload using a signing secret you provide. Configure it in your endpoint replay settings with your local STRIPE_WEBHOOK_SECRET, and replayed events will carry a valid signature your handler can verify.
Replaying failure scenarios
Stripe retries failed deliveries, but testing retry behavior in Stripe’s dashboard is cumbersome. With HookCap, you can:
- Replay an
invoice.payment_failedevent while your handler is deliberately broken, then replay again after fixing it - Test your idempotency logic by replaying the same event ID twice
Common Stripe Webhook Gotchas
Signature verification fails with parsed body
This is the most common issue. The signature is computed over the raw request body as bytes. If you parse the body as JSON before passing it to stripe.webhooks.constructEvent(), verification fails.
In Express:
// Correct: use raw body on the webhook route
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, // Buffer, not object
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// handle event...
res.json({ received: true });
}
);
In Hono (Cloudflare Workers):
app.post('/webhooks/stripe', async (c) => {
const rawBody = await c.req.text();
const sig = c.req.header('stripe-signature') ?? '';
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return c.text(`Webhook Error: ${err}`, 400);
}
// handle event...
return c.json({ received: true });
});
subscription_cancelled vs subscription_deleted
Stripe has two distinct flows:
- Cancel at period end:
customer.subscription.updatedfires withcancel_at_period_end: true. The subscription is still active and will deliver value until the period ends. - Immediate cancellation:
customer.subscription.deletedfires.
If you downgrade a user’s account on subscription.updated instead of subscription.deleted, you cut off access before they have used the time they paid for. Keep the account active until subscription.deleted arrives — or better, until the timestamp in cancel_at passes.
Webhook route must come before authentication middleware
If your API framework applies global authentication middleware, the Stripe webhook route must be excluded. Stripe does not send auth tokens — it uses its own signature header. Mounting your webhook route after a requireAuth() middleware will cause all Stripe deliveries to return 401.
In Express:
// Webhook route BEFORE auth middleware
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), stripeWebhookHandler);
// Auth middleware applies to everything else
app.use(requireAuth);
app.get('/api/user', userHandler);
Respond before processing
Stripe expects a 2xx response within 20 seconds. If your handler does database writes, sends emails, or calls other services synchronously, you risk timeouts on busy handlers.
Acknowledge immediately, process in the background:
app.post('/webhooks/stripe', async (req, res) => {
const event = parseAndVerify(req);
// Respond immediately
res.status(200).json({ received: true });
// Process after response
setImmediate(async () => {
await processEvent(event);
});
});
Test mode vs. live mode endpoint separation
Test mode webhooks require a test mode endpoint. Live events only go to live mode endpoints. If you configure your HookCap endpoint in test mode and then switch to live mode in Stripe, the events will stop arriving at your endpoint — silently.
Keep separate HookCap endpoints for test and live environments, and label them clearly.
Putting It Together
A practical Stripe webhook development workflow with HookCap:
- Create a HookCap endpoint and register it in Stripe test mode
- Run through checkout flows in test mode to capture real events
- Inspect payloads in HookCap to understand exactly what your handler will receive
- Write your handlers using the payload structure you observed
- Replay events against your local server to verify handling
- Once confident, deploy and register your production URL in Stripe live mode
HookCap’s persistent history means you can return to an event hours later, share the URL with a teammate for debugging, or use a captured payload as a fixture in your test suite.
Free tier available at hookcap.dev. Paid plans start at $12/month with webhook replay and longer history retention.