Stripe sends subscription lifecycle events via webhooks. Listen for customer.subscription.created, updated, deleted, and invoice.payment_failed to keep your app in sync. Set up a webhook endpoint, verify signatures with stripe.webhooks.constructEvent, and handle each event type to manage access, send emails, and update your database.
Why Subscription Webhooks Are Essential
Stripe uses webhooks to notify your server about subscription changes in real time. Without webhooks, your app has no reliable way to know when a customer upgrades, downgrades, cancels, or fails a payment. Polling the API is slow and wasteful. Webhooks give you instant, push-based notifications so you can update user access, trigger emails, and keep your billing database accurate.
Prerequisites
- A Stripe account in test mode with a product and price created
- Node.js 18+ and npm installed locally
- Basic familiarity with Express.js or a similar Node.js framework
- Stripe CLI installed for local webhook testing
- A database or user management system to update subscription status
Step-by-step guide
Install Stripe SDK and set up Express
Install Stripe SDK and set up Express
Initialize your project and install the Stripe Node.js library along with Express. Store your Stripe secret key and webhook signing secret as environment variables — never hard-code them.
1npm init -y2npm install stripe express dotenvExpected result: Project initialized with stripe, express, and dotenv in package.json dependencies.
Create the webhook endpoint with raw body parsing
Create the webhook endpoint with raw body parsing
Stripe requires the raw request body to verify webhook signatures. Use express.raw() for the webhook route instead of express.json(). This is the most common source of signature verification failures.
1const express = require('express');2const app = express();34// IMPORTANT: webhook route must use raw body5app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {6 // Webhook handling goes here7 res.sendStatus(200);8});910// Other routes use JSON parsing11app.use(express.json());1213app.listen(3000, () => console.log('Server running on port 3000'));Expected result: Express server running on port 3000 with a /webhook route accepting raw body POST requests.
Verify the webhook signature
Verify the webhook signature
Use stripe.webhooks.constructEvent to verify that the event genuinely came from Stripe. This prevents attackers from sending fake events to your endpoint.
1const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);2const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;34app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {5 const sig = req.headers['stripe-signature'];6 let event;78 try {9 event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);10 } catch (err) {11 console.error('Webhook signature verification failed:', err.message);12 return res.status(400).send(`Webhook Error: ${err.message}`);13 }1415 // Handle the event16 res.json({ received: true });17});Expected result: Webhook endpoint verifies each incoming event signature. Invalid signatures return 400; valid events return 200.
Handle key subscription events
Handle key subscription events
Route each event type to the appropriate handler. The most important subscription events are: customer.subscription.created (new sub), customer.subscription.updated (plan change, renewal), customer.subscription.deleted (cancellation), and invoice.payment_failed (payment issue).
1switch (event.type) {2 case 'customer.subscription.created':3 const newSub = event.data.object;4 await activateSubscription(newSub.customer, newSub.id, newSub.items.data[0].price.id);5 break;67 case 'customer.subscription.updated':8 const updatedSub = event.data.object;9 await updateSubscription(updatedSub.customer, updatedSub.status, updatedSub.items.data[0].price.id);10 break;1112 case 'customer.subscription.deleted':13 const canceledSub = event.data.object;14 await deactivateSubscription(canceledSub.customer);15 break;1617 case 'invoice.payment_failed':18 const failedInvoice = event.data.object;19 await handleFailedPayment(failedInvoice.customer, failedInvoice.id);20 break;2122 default:23 console.log(`Unhandled event type: ${event.type}`);24}Expected result: Each subscription lifecycle event triggers the corresponding business logic in your application.
Test with the Stripe CLI
Test with the Stripe CLI
Use the Stripe CLI to forward test webhook events to your local server. This lets you simulate the full subscription lifecycle without creating real subscriptions.
1# In terminal 1 — forward events to your local server2stripe listen --forward-to localhost:3000/webhook34# In terminal 2 — trigger a test event5stripe trigger customer.subscription.createdExpected result: Stripe CLI forwards the test event to your local server. Your console logs confirm the event was received and processed.
Register the webhook in the Stripe Dashboard for production
Register the webhook in the Stripe Dashboard for production
Go to Stripe Dashboard → Developers → Webhooks → Add endpoint. Enter your production URL (e.g., https://yourapp.com/webhook). Select the events: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed. Copy the signing secret and add it to your production environment variables.
Expected result: Webhook endpoint registered in Stripe Dashboard. Stripe will send subscription events to your production URL.
Complete working example
1require('dotenv').config();2const express = require('express');3const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);45const app = express();6const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;78// Webhook route — must use raw body for signature verification9app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {10 const sig = req.headers['stripe-signature'];11 let event;1213 try {14 event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);15 } catch (err) {16 console.error('Webhook signature verification failed:', err.message);17 return res.status(400).send(`Webhook Error: ${err.message}`);18 }1920 try {21 switch (event.type) {22 case 'customer.subscription.created': {23 const sub = event.data.object;24 console.log(`New subscription ${sub.id} for customer ${sub.customer}`);25 // TODO: activate user access in your database26 break;27 }28 case 'customer.subscription.updated': {29 const sub = event.data.object;30 console.log(`Subscription ${sub.id} updated — status: ${sub.status}`);31 // TODO: update plan tier, handle trial end, etc.32 break;33 }34 case 'customer.subscription.deleted': {35 const sub = event.data.object;36 console.log(`Subscription ${sub.id} canceled for customer ${sub.customer}`);37 // TODO: revoke user access38 break;39 }40 case 'invoice.payment_failed': {41 const invoice = event.data.object;42 console.log(`Payment failed for invoice ${invoice.id}, customer ${invoice.customer}`);43 // TODO: notify user, flag account44 break;45 }46 default:47 console.log(`Unhandled event type: ${event.type}`);48 }49 } catch (err) {50 console.error('Error processing webhook:', err);51 return res.status(500).json({ error: 'Webhook handler failed' });52 }5354 res.json({ received: true });55});5657// Other routes use JSON58app.use(express.json());5960app.get('/', (req, res) => {61 res.send('Stripe Subscription Webhook Server');62});6364const PORT = process.env.PORT || 3000;65app.listen(PORT, () => console.log(`Server running on port ${PORT}`));Common mistakes when listening to subscription events in Stripe
Why it's a problem: Using express.json() for the webhook route instead of express.raw()
How to avoid: Stripe signature verification requires the raw request body. Use express.raw({ type: 'application/json' }) specifically for your webhook endpoint.
Why it's a problem: Not returning a 200 response quickly enough
How to avoid: Stripe expects a 2xx response within 20 seconds. Process heavy logic asynchronously (e.g., queue the event) and return 200 immediately.
Why it's a problem: Using the test mode webhook secret in production
How to avoid: Test and live mode have separate webhook signing secrets. Update your STRIPE_WEBHOOK_SECRET environment variable when deploying to production.
Why it's a problem: Not handling duplicate events
How to avoid: Stripe may send the same event more than once. Store processed event IDs and check for duplicates before processing.
Best practices
- Always verify webhook signatures using stripe.webhooks.constructEvent — never trust raw POST data
- Return a 200 status code immediately and process events asynchronously for heavy operations
- Store the event ID and check for duplicates to make your webhook handler idempotent
- Log all received events with their type and ID for debugging
- Use the Stripe CLI to test webhooks locally before deploying
- Subscribe only to the events you need — avoid catching all events unnecessarily
- Set up alerts for repeated webhook failures in the Stripe Dashboard
- Keep your webhook signing secret in environment variables, never in source code
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a Node.js Express webhook endpoint that listens for Stripe subscription events (created, updated, deleted, invoice.payment_failed). Verify the webhook signature using stripe.webhooks.constructEvent with raw body parsing. Include error handling and a switch statement for each event type.
Build a Stripe webhook handler in Node.js that processes subscription lifecycle events. Use express.raw() for the webhook route, verify signatures with constructEvent, and handle customer.subscription.created, updated, deleted, and invoice.payment_failed events.
Frequently asked questions
Which Stripe subscription events should I listen to?
At minimum, listen to customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, and invoice.payment_failed. These cover the full subscription lifecycle: activation, plan changes, cancellation, and payment failures.
How do I test subscription webhooks locally?
Install the Stripe CLI and run 'stripe listen --forward-to localhost:3000/webhook'. This forwards events to your local server. Use 'stripe trigger customer.subscription.created' to simulate events.
Why is my webhook returning a 400 error?
The most common cause is parsing the request body as JSON before signature verification. Your webhook route must use express.raw({ type: 'application/json' }) instead of express.json() to preserve the raw body for signature checking.
Can Stripe send the same webhook event twice?
Yes. Stripe retries failed webhook deliveries up to 3 days. Always store processed event IDs and check for duplicates to make your handler idempotent.
What happens if my webhook endpoint is down?
Stripe retries webhook deliveries with exponential backoff over 72 hours. After repeated failures, Stripe disables the endpoint and sends you an email notification. You can manually retry missed events from the Dashboard.
Should I process webhook events synchronously or asynchronously?
Return a 200 response immediately and process the event asynchronously. Stripe times out webhook requests after 20 seconds. Use a message queue or background job for heavy processing like database updates or email sends.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation