Webhooks Fresh
Receive real-time notifications when events happen in your Stripe account. Webhooks are how your integration reacts to asynchronous events like successful payments, subscription renewals, and disputes.
How webhooks work
- An event occurs in Stripe (payment succeeds, subscription renews, etc.)
- Stripe sends an HTTPS POST to your webhook endpoint URL
- The request body contains an Event object in JSON
- Your endpoint returns a
2xxstatus within the timeout window - If Stripe doesn't receive a
2xx, it retries with exponential backoff
Create a webhook handler
javascript
const express = require('express');
const stripe = require('stripe')('sk_test_YOUR_KEY');
const app = express();
// IMPORTANT: Use express.raw() for webhooks — not express.json()
// express.json() modifies the body, breaking signature verification
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('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Return 200 quickly, then process the event
res.json({ received: true });
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
handlePaymentSuccess(paymentIntent);
break;
case 'payment_intent.payment_failed':
const failedPaymentIntent = event.data.object;
handlePaymentFailure(failedPaymentIntent);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
const subscription = event.data.object;
handleSubscriptionChange(subscription);
break;
case 'customer.subscription.deleted':
const canceledSubscription = event.data.object;
handleSubscriptionCanceled(canceledSubscription);
break;
case 'invoice.paid':
const invoice = event.data.object;
handleInvoicePaid(invoice);
break;
case 'invoice.payment_failed':
const failedInvoice = event.data.object;
handleInvoicePaymentFailed(failedInvoice);
break;
default:
console.log('Unhandled event type:', event.type);
}
});
app.listen(4242);Register your webhook endpoint
Create a webhook endpoint in Dashboard → Developers → Webhooks → Add endpoint.
Or via API:
bash
curl https://api.stripe.com/v1/webhook_endpoints \
-u "sk_test_YOUR_KEY:" \
-d url="https://example.com/webhook" \
-d "enabled_events[]=payment_intent.succeeded" \
-d "enabled_events[]=invoice.paid" \
-d "enabled_events[]=customer.subscription.deleted"Signature verification
Always verify the Stripe-Signature header to confirm events come from Stripe:
javascript
// The three parameters must be exactly right:
// 1. req.body - raw bytes, not parsed JSON
// 2. sig - the Stripe-Signature header value
// 3. endpointSecret - starts with whsec_
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);Common signature verification errors
| Error | Cause | Fix |
|---|---|---|
No signatures found matching... | Wrong endpoint secret | Use the secret from Dashboard → Webhooks → your endpoint → Reveal secret |
Timestamp outside the tolerance zone | Request body was buffered too long | Return 200 before complex processing |
| Verification fails in Express | Body parser applied before webhook route | Put webhook route BEFORE app.use(express.json()) |
| Verification fails with Next.js App Router | Body parsed as JSON by Next.js | Use await request.text() or await request.arrayBuffer() to get raw body |
Express middleware order (critical)
javascript
// CORRECT: webhook route BEFORE body parser
app.post('/webhook', express.raw({ type: 'application/json' }), webhookHandler);
app.use(express.json()); // This comes AFTER webhook route
app.post('/api/other-route', otherHandler);
// WRONG: body parser applied first breaks webhook signature
app.use(express.json()); // This breaks webhook verification
app.post('/webhook', webhookHandler);Next.js App Router
javascript
// app/api/webhooks/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// Handle event
return new Response(JSON.stringify({ received: true }));
}Endpoint secret types
| Secret | Starts with | Use |
|---|---|---|
| Dashboard endpoint secret | whsec_... | For endpoints registered in Dashboard |
| Stripe CLI secret | whsec_... | Printed when running stripe listen — different from Dashboard secret |
Do not use a Dashboard secret to verify CLI-forwarded events, or vice versa.
Testing locally with Stripe CLI
bash
# Install Stripe CLI, then:
stripe listen --forward-to localhost:4242/webhook
# In another terminal, trigger test events:
stripe trigger payment_intent.succeeded
stripe trigger invoice.paid
stripe trigger customer.subscription.createdThe CLI prints the webhook secret (whsec_...) when you run stripe listen. Use that as your STRIPE_WEBHOOK_SECRET during local development.
Retry behavior
If your endpoint doesn't return a 2xx within 30 seconds, Stripe retries with exponential backoff over the next 3 days.
Retry schedule (approximate):
- 5 minutes
- 30 minutes
- 2 hours
- 5 hours
- 10 hours
- 24 hours (then daily until 3 days)
Idempotency in webhook handlers
Stripe may send the same event more than once. Make your handlers idempotent:
javascript
// Track processed event IDs to avoid double-processing
const processedEvents = new Set();
async function handleWebhookEvent(event) {
if (processedEvents.has(event.id)) {
return; // Already processed
}
// Process the event
await processEvent(event);
// Mark as processed
processedEvents.add(event.id);
// In production: store in database with TTL
}Event object structure
json
{
"id": "evt_1Nxxx",
"object": "event",
"type": "payment_intent.succeeded",
"created": 1234567890,
"livemode": false,
"data": {
"object": { ... }, // The Stripe object that triggered the event
"previous_attributes": {} // Changed fields (only on *.updated events)
},
"request": {
"id": "req_xxx",
"idempotency_key": null
},
"account": "acct_xxx" // Only on Connect events — which connected account
}Common webhook events
| Event | When it fires |
|---|---|
payment_intent.succeeded | Payment confirmed and funds captured |
payment_intent.payment_failed | Payment failed |
payment_intent.processing | Payment processing (bank transfers) |
invoice.paid | Invoice payment succeeded |
invoice.payment_failed | Invoice payment failed |
invoice.finalized | Invoice finalized and ready to send |
customer.subscription.created | New subscription created |
customer.subscription.updated | Subscription changed |
customer.subscription.deleted | Subscription canceled |
checkout.session.completed | Checkout session completed |
charge.dispute.created | Dispute opened |
charge.refunded | Charge refunded |
account.updated | Connected account updated |
payout.paid | Payout successfully sent to bank |
payout.failed | Payout failed |