Stripe's webhook system is one of the most well-designed in the industry. It has clear documentation, built-in retry logic (it retries for 3 days), a solid test mode, and a CLI for local development.
But there are still plenty of ways to get it wrong — missed events, duplicate charges, ignored retries. This guide covers everything from local setup to production-grade reliability.
Why Stripe Webhooks Matter
Stripe's synchronous API calls (creating PaymentIntents, retrieving Customers) are deterministic. Webhooks are how Stripe tells you about asynchronous events:
| Without webhooks | With webhooks |
|---|---|
| Poll for payment status | Get notified instantly |
| Miss failed payments | React to invoice.payment_failed immediately |
| Manually check subscription status | Handle customer.subscription.deleted |
| Delayed fulfillment | Trigger fulfillment on payment_intent.succeeded |
The most critical webhooks for a typical SaaS:
| Event | Why it matters |
|---|---|
payment_intent.succeeded | Provision product, trigger fulfillment |
payment_intent.payment_failed | Notify customer, initiate dunning |
customer.subscription.created | Provision subscription features |
customer.subscription.updated | Handle plan changes |
customer.subscription.deleted | Revoke access |
invoice.payment_succeeded | Record payment, extend subscription |
invoice.payment_failed | Start dunning process |
customer.created | Sync to CRM |
charge.dispute.created | Alert risk team, freeze account |
Local Development Setup
Install the Stripe CLI
# macOS
brew install stripe/stripe-cli/stripe
# Linux
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update && sudo apt install stripe
# Windows
scoop install stripe
Forward Webhooks Locally
# Log in to Stripe CLI
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:8080/webhooks/stripe
The CLI will print a webhook signing secret like:
> Ready! Your webhook signing secret is whsec_test_...
Use this as your STRIPE_WEBHOOK_SECRET env var in local development.
Trigger Test Events
# Trigger a payment_intent.succeeded event
stripe trigger payment_intent.succeeded
# Trigger a subscription deletion
stripe trigger customer.subscription.deleted
# List all available events
stripe trigger --help
Implementing the Webhook Handler
Go Implementation
package handler
import (
"encoding/json"
"io"
"net/http"
"os"
"github.com/stripe/stripe-go/v76/webhook"
)
type StripeHandler struct {
webhookSecret string
// ... your services
}
func NewStripeHandler() *StripeHandler {
return &StripeHandler{
webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
}
}
func (h *StripeHandler) Handle(w http.ResponseWriter, r *http.Request) {
// Read body — important: must be raw bytes for signature verification
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", 400)
return
}
// Verify signature using Stripe's official library
event, err := webhook.ConstructEvent(
body,
r.Header.Get("Stripe-Signature"),
h.webhookSecret,
)
if err != nil {
// Return 400 for signature mismatch
// Note: Stripe treats 4xx as permanent failure (no retry)
// But signature failure should 400 — don't retry fake events
http.Error(w, "invalid signature", 400)
return
}
// ⚡ RESPOND IMMEDIATELY — before processing
w.WriteHeader(200)
// Process asynchronously
go h.processEvent(event)
}
func (h *StripeHandler) processEvent(event stripe.Event) {
switch event.Type {
case "payment_intent.succeeded":
h.handlePaymentSucceeded(event)
case "customer.subscription.deleted":
h.handleSubscriptionCanceled(event)
case "invoice.payment_failed":
h.handleInvoicePaymentFailed(event)
// ... etc
default:
// Log unknown events for monitoring
log.Info("unhandled stripe event", "type", event.Type)
}
}
Node.js / TypeScript Implementation
import Stripe from "stripe";
import express from "express";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// IMPORTANT: raw body middleware — must come before json() for this route
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"]!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
console.error("Signature verification failed:", err);
return res.status(400).send("Invalid signature");
}
// Respond immediately
res.status(200).json({ received: true });
// Process in background
await processEvent(event);
},
);
Critical Node.js gotcha: You must use express.raw() for the webhook route — not express.json(). Stripe's signature verification requires the raw request body before JSON parsing.
Idempotency: Handling Duplicate Events
Stripe retries failed webhooks. You WILL receive duplicates. Here's how to handle them:
func (h *StripeHandler) handlePaymentSucceeded(event stripe.Event) {
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
log.Error("failed to parse payment intent", "error", err)
return
}
// Use Stripe Event ID as idempotency key
key := "stripe_event:" + event.ID
if already, _ := h.cache.Get(key); already != nil {
log.Info("skipping duplicate event", "event_id", event.ID)
return
}
// Mark as processing (24h TTL handles crash recovery)
h.cache.SetEx(key, 24*time.Hour, "processing")
// Do the work
if err := h.db.CreateOrder(pi.ID, pi.Amount); err != nil {
h.cache.Delete(key) // Allow retry on DB failure
return
}
h.cache.Set(key, "done")
}
Or use your database if you don't have Redis:
-- Add idempotency table
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
_, err := db.Exec(`
INSERT INTO processed_events (event_id) VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
`, event.ID)
if err == nil && rowsAffected == 0 {
// Duplicate — already processed
return
}
Critical Payment Events and How to Handle Them
payment_intent.succeeded
func handlePaymentSucceeded(event stripe.Event) {
var pi stripe.PaymentIntent
json.Unmarshal(event.Data.Raw, &pi)
// Fulfill the order
orderID := pi.Metadata["order_id"] // set this when creating the PI
db.UpdateOrderStatus(orderID, "paid")
fulfillmentQueue.Enqueue(orderID)
emailQueue.Enqueue("order_confirmation", pi.ReceiptEmail)
}
customer.subscription.deleted
This fires both when a subscription is cancelled immediately AND when a trial expires without payment.
func handleSubscriptionDeleted(event stripe.Event) {
var sub stripe.Subscription
json.Unmarshal(event.Data.Raw, &sub)
customerID := sub.Customer.ID
// Revoke feature access
db.SetCustomerPlan(customerID, "free")
// Send cancellation email (but not if trial expired — check sub.TrialEnd)
if sub.TrialEnd == 0 {
emailQueue.Enqueue("cancellation_confirmation", customerID)
} else {
emailQueue.Enqueue("trial_expired", customerID)
}
}
invoice.payment_failed
func handleInvoicePaymentFailed(event stripe.Event) {
var invoice stripe.Invoice
json.Unmarshal(event.Data.Raw, &invoice)
// Check how many times this has failed
attemptCount := invoice.AttemptCount
switch {
case attemptCount == 1:
emailQueue.Enqueue("payment_failed_first", invoice.CustomerEmail)
case attemptCount == 2:
emailQueue.Enqueue("payment_failed_second", invoice.CustomerEmail)
case attemptCount >= 3:
// Consider downgrading or pausing the account
emailQueue.Enqueue("payment_failed_final", invoice.CustomerEmail)
db.SetCustomerStatus(invoice.Customer.ID, "past_due")
}
}
Stripe's Retry Behavior
Understanding Stripe's retry schedule helps you design your handler:
| Attempt | Timing |
|---|---|
| 1 | Immediately after event |
| 2 | ~1 hour later |
| 3 | ~12 hours later |
| 4 | ~24 hours later |
| 5–8 | Daily |
| Total | 3 days of retries |
Stripe retries when your endpoint returns 5xx or times out. It does NOT retry on 4xx (except 429).
If your endpoint becomes permanently unavailable, Stripe will eventually disable it and email you.
Reliability: Using GetHook as a Webhook Gateway
The approach above works well for simple use cases. For production reliability — especially if you have multiple Stripe integrations, fan-out routing, or need event replay — using GetHook as a gateway gives you:
Stripe ──→ GetHook ingest endpoint ──→ (verified + queued) ──→ Your handler
Benefits:
- ›Your server can restart — GetHook buffers events during deploys
- ›Fan-out — route
payment_intent.succeededto billing, fulfillment, and analytics independently - ›Replay — re-deliver any event from the last 90 days
- ›Delivery logs — per-event delivery timeline with HTTP responses
To set up Stripe with GetHook:
- ›Create a Source in GetHook with
auth_mode: stripe(useswhsec_format verification) - ›Set the GetHook ingest URL as your Stripe webhook endpoint
- ›Create Destinations for each internal service
- ›Create Routes to map event types to destinations
# Create a Stripe source
curl -X POST https://api.gethook.to/v1/sources \
-H "Authorization: Bearer hk_..." \
-d '{"name": "Stripe Production", "auth_mode": "stripe"}'
# Create a destination
curl -X POST https://api.gethook.to/v1/destinations \
-H "Authorization: Bearer hk_..." \
-d '{"name": "Billing Service", "url": "https://api.yourapp.com/internal/billing/webhook"}'
# Create a route
curl -X POST https://api.gethook.to/v1/routes \
-H "Authorization: Bearer hk_..." \
-d '{"source_id": "...", "destination_id": "...", "event_type_pattern": "payment_intent.*"}'
Your internal services now receive pre-verified events with GetHook's retry and dead-letter infrastructure behind them.
Testing in Production
Stripe's test mode is excellent for development, but you should also have a process for testing in production:
- ›Use Stripe's webhook test feature — Dashboard → Developers → Webhooks → Send test webhook
- ›Make idempotent test events — Create real test payments in live mode using Stripe's test cards
- ›Monitor dead-letter queue — Set up alerts for DLQ growth after any deployment
- ›Load test — Use
stripe triggerin a loop to simulate burst delivery
Checklist
- › Stripe CLI set up for local development
- ›
STRIPE_WEBHOOK_SECRETset in environment (separate secrets for test/live) - › Raw body middleware before JSON parser (Node.js)
- › Signature verification on every request
- › Respond 200 before processing
- › Idempotency keys for all mutation handlers
- › Handlers for all critical events (
payment_intent.*,subscription.*,invoice.*) - › Dead-letter queue with monitoring
- › Test webhook delivery after every deploy