Most Stripe webhook bugs are not business logic bugs. They are reproducibility bugs. If you can't replay the exact event path in under 30 seconds, debugging takes hours instead of minutes — because every attempt involves refreshing the Stripe Dashboard, re-triggering a test, and squinting at logs to figure out whether your handler ran at all.
This post walks through the local loop that makes this painless: forward → trigger → replay → inspect. Four steps, one terminal window, no guessing.
The loop, in full
# Terminal 1 — forward Stripe events to your local handler
stripe listen --forward-to "http://localhost:3000/api/stripe-webhook"
# Terminal 2 — trigger real Stripe test events
stripe trigger payment_intent.succeeded
stripe trigger charge.failed
stripe trigger invoice.payment_failed
# When something fails, replay the exact event
stripe events resend evt_1NfP6Q2eZvKYlo2CsKT4a5oS
That is the whole loop. Everything below is how to make it reliable.
Step 1 — Give your dev webhook its own path
Don't forward Stripe events to your production webhook path. Use a dedicated dev path:
stripe listen --forward-to "http://localhost:3000/api/stripe-webhook-dev"
Why separate? Because you want to test with lax validation (no signature verification, verbose logging) without loosening your production handler. Keep two paths:
-
/api/stripe-webhook— production. Strict signature verification, minimal logs. -
/api/stripe-webhook-dev— dev only. Signature check optional, logs every field.
When stripe listen starts, it prints a signing secret:
> Ready! Your webhook signing secret is whsec_abc123...
Copy that into your dev environment variable (STRIPE_WEBHOOK_SECRET_DEV). This is NOT the same as the secret from your Stripe Dashboard — the CLI generates its own. This trips up everyone the first time.
Step 2 — Trigger real events, not made-up payloads
Do not hand-craft JSON payloads. Stripe's trigger command sends a real event through your account's test data, which means you're testing against payloads that match production exactly.
# Core payment flows
stripe trigger payment_intent.succeeded
stripe trigger charge.succeeded
stripe trigger charge.failed
# Subscription lifecycle
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
# Invoice / billing
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
Each trigger writes a real event to your Stripe test account. That means every event has a real id (starts with evt_) that you can replay later.
Run all the events your handler cares about at least once. Anything you haven't triggered locally is a surprise waiting in production.
Step 3 — Replay the exact event, on demand
This is the step most people skip, and it's where debugging speed compounds. When a test fails, you can replay the exact same event by ID:
stripe events resend evt_1NfP6Q2eZvKYlo2CsKT4a5oS
Same payload. Same signature. Same timestamp. Your handler sees a byte-for-byte identical request.
This is gold for two reasons:
- Idempotency testing. Your handler should be safe to call with the same event ID twice. Replay it five times in a row and confirm you create one record, not five. If you create five, you have a bug.
-
Deterministic debugging. When something fails at 2am, you can add a
console.log, restart your server, and replay the same event. No hunting for a new trigger, no hoping you can reproduce the path.
Step 4 — Validate via logs, not hope
For every event, verify:
- ✓ Your handler returned 200 (Stripe will retry otherwise)
- ✓ The event ID was logged
- ✓ A record was created (first time)
- ✓ A replay was skipped (second time — idempotency)
- ✓ Unknown event types no-op safely (don't throw)
A minimal handler that makes this visible:
export default async function handler(req, res) {
const event = verifySignature(req); // or skip on dev path
console.log(`[stripe] ${event.type} ${event.id}`);
const existing = await findEvent(event.id);
if (existing) {
console.log(`[stripe] skipped duplicate ${event.id}`);
return res.json({ received: true, skipped: true });
}
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(event.data.object);
break;
case 'charge.failed':
await handleChargeFailed(event.data.object);
break;
default:
console.log(`[stripe] no-op for ${event.type}`);
}
await recordEvent(event);
res.json({ received: true });
}
Three rules this enforces:
- Log the event ID on every call. When something goes wrong, the event ID is the only key that connects Stripe's dashboard to your logs.
-
Return 200 even for no-ops. Stripe retries non-200 responses. A
throwon an unknown event type will get retried for 3 days and fill up your logs. - Make duplicate detection visible. When you replay for testing, you want to SEE that the skip branch fired.
Pre-production checklist
Before you switch Stripe from your CLI forwarder to your real endpoint:
- [ ] Every event type you care about has been triggered locally at least once
- [ ] Each one has been replayed and the idempotency branch ran
- [ ] At least one unknown event type was sent — handler returned 200, did not throw
- [ ] Signature verification works in prod mode with the real Dashboard secret (not the CLI secret)
- [ ] Handler returns 200 in under 5 seconds for all event types (Stripe times out at 30s but backs off aggressively if you're slow)
- [ ] Logs include event ID on every line relevant to the event
What this costs
Nothing. The Stripe CLI is free. stripe trigger uses your test mode data. stripe events resend uses events you already generated. You don't need a tunnel service (ngrok, localtunnel) — stripe listen does the forwarding itself.
What to pair this with
If you want stored event history you can query later (replay a 2-week-old event, audit a customer's event sequence, etc.), you need something between Stripe and your handler that logs every event permanently. Stripe's own Events API only goes back 30 days and can't filter by fields you care about.
Centrali's webhook triggers do this out of the box — every inbound event is stored in a collection you can query later. But the testing loop above works with any handler, framework, or platform. The important part is the loop.
If this was useful, I write more about webhook reliability and Stripe integration patterns. Follow me for more.

Top comments (0)