Stripe webhooks power the parts of your app where money moves — subscription lifecycle, payment confirmations, invoice finalization, dispute handling. When they break, it's not a "we'll fix it Monday" kind of problem. This guide walks through practical Stripe webhook debugging using CatchHook, from initial setup through the failures that wake you up at 2am.
Setting Up a Stripe Webhook Endpoint
In CatchHook
Create a new endpoint. You'll get a URL like:
https://your-account.catchhook.app/in/ep_abc123
In Stripe's Dashboard
- Go to Developers → Webhooks in your Stripe Dashboard
- Click Add endpoint
- Paste your CatchHook endpoint URL
- Select the events you want — start narrow (
checkout.session.completed,invoice.paid,customer.subscription.updated) and expand later - Save and copy the Signing secret (
whsec_...)
Configuring Signature Verification
Add your Stripe signing secret to the CatchHook endpoint's signature configuration. Once that's in place, CatchHook verifies every incoming request and shows a clear pass/fail on each event.
Without the secret configured, CatchHook still captures everything — you just won't get the automatic verification status.
Understanding Stripe's Event Structure
Every Stripe webhook body looks like this:
{
"id": "evt_1234567890",
"object": "event",
"type": "invoice.paid",
"api_version": "2024-12-18.acacia",
"data": {
"object": {
"id": "in_1234567890",
"amount_paid": 2000,
"currency": "usd"
}
},
"livemode": false,
"request": {
"id": "req_abc123",
"idempotency_key": null
}
}
The fields that matter most for debugging:
id— unique event identifier, your idempotency key (see Webhook Retries and Idempotency)type— the event name. CatchHook parses this and shows it as the provider event type in the request listapi_version— the API version used to render the payload. This one bites people more than they expectlivemode—falsefor test mode. Always check this in your handlerrequest.id— the API call that triggered this event, useful for tracing back to your own code
The API Version Gotcha
Here's something that catches a lot of people: Stripe renders webhook payloads using the API version set on your webhook endpoint, not your account's default API version. If your endpoint is on 2024-12-18.acacia but your code expects the 2023-10-16 schema, fields might be missing or structured differently.
This causes incredibly subtle bugs:
- A field that works in development breaks in production because the endpoint versions differ
- Updating your Stripe gem changes the expected schema but not the webhook endpoint version
- Test mode and live mode endpoints silently use different API versions
When a Stripe webhook arrives in CatchHook, check the api_version in the payload and compare it against what your Stripe SDK expects. If they're different, update the webhook endpoint's version in Stripe's settings.
Common Stripe Webhook Failures
Signature Verification Errors
The single most common failure. Usually one of these:
- Wrong signing secret — each endpoint has its own
whsec_...secret. Multiple endpoints (test, live, different environments) means multiple secrets. Make sure you're using the right one. - Body parsing before verification — your framework consumed the JSON body before your handler read the raw bytes. See Webhook Signature Verification.
- Expired timestamp — Stripe rejects signatures older than 5 minutes by default. Clock skew on your server will cause this.
CatchHook shows exactly which verification step failed when the signing secret is configured on the endpoint.
Missing Event Types
You subscribed to checkout.session.completed but forgot about checkout.session.expired for abandoned checkouts. Stripe only sends events you've explicitly selected.
Point CatchHook at all Stripe event types during development. You'll quickly see the full lifecycle of whatever flow you're building, and you can pare down to just what your handler needs later.
Duplicate Side Effects
A payment_intent.succeeded event triggers a fulfillment email. Stripe retries because your server was slow, and now the customer gets two emails. Classic.
return if ProcessedStripeEvent.exists?(event_id: event["id"])
ProcessedStripeEvent.create!(event_id: event["id"])
fulfill_order(event)
send_confirmation_email(event)
Test Mode vs. Live Mode
Test mode and live mode are completely separate. Events from test mode have "livemode": false. Your handler should sanity-check this:
unless event["livemode"] || Rails.env.development?
Rails.logger.warn("Received test mode event in production")
head :ok
return
end
The CatchHook Debugging Workflow
1. Capture the Event
Point your Stripe test mode webhook at a CatchHook endpoint. Trigger a test event through Stripe's dashboard or with the Stripe CLI (stripe trigger payment_intent.succeeded).
2. Inspect the Payload
In CatchHook, click the event. You'll see:
- Provider detection: "Stripe" with the event type (e.g.,
invoice.paid) - Signature status: verified, failed, or unconfigured
- Full headers and body: the exact bytes Stripe sent
- AI summary: a plain-English explanation of what the event represents
3. Tunnel Stripe Webhooks to Your Local Server
To actually test your handler against real Stripe traffic, start the CatchHook tunnel:
npx @catchhook/tunnel start --provider stripe --port 3000
The --provider stripe flag is the key part — it sets up signing secrets, configures the default webhook path, and prints exactly what to paste into Stripe's dashboard. From this point on, the tunnel streams every webhook Stripe sends to your CatchHook endpoint to localhost:3000/webhooks/stripe in real time.
For Stripe delivery to your local machine, use the tunnel. It creates a persistent connection from your machine to CatchHook and streams events to your handler, while replay and Endpoint Action Forward steps remain available for remote destinations.
4. Compare Events
When something works in test mode but breaks with a specific customer, capture both events and use CatchHook's diff view to compare them side by side. This instantly surfaces structural differences — missing fields, different nested object shapes, unexpected null values.
5. Diagnose with AI
For complex payloads, use CatchHook's AI diagnostics. It understands Stripe's event schema and can explain what the event means, flag common pitfalls for that event type, and suggest handler logic.
Stripe CLI vs. CatchHook Tunnel
Stripe has its own CLI for local webhook testing (stripe listen --forward-to localhost:3000/webhooks/stripe). Both work for getting events to localhost. Here's the difference:
| Need | Stripe CLI | CatchHook |
|---|---|---|
| Tunnel Stripe events to localhost | Yes | Yes |
| Inspect events in a web UI | No (terminal only) | Yes |
| Capture events for later review | No | Yes |
| Edit payloads and re-trigger | No | Yes |
| Work with non-Stripe webhooks too | No | Yes |
| Compare two events side-by-side | No | Yes |
| AI payload summaries | No | Yes |
| Team sharing | No | Yes |
If you're only ever touching Stripe and you're fine with terminal-only inspection, the Stripe CLI does the job. If you're working with multiple providers, want to collaborate with teammates, or need to compare and edit payloads, CatchHook covers more ground.