# How to listen to subscription events in Stripe

- Tool: Stripe
- Difficulty: Intermediate
- Time required: 25 minutes
- Compatibility: Stripe API v2024-12+, Node.js 18+, Express 4+
- Last updated: March 2026

## TL;DR

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.

## Before you start

- 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

### 1. 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.

```
npm init -y
npm install stripe express dotenv
```

**Expected result:** Project initialized with stripe, express, and dotenv in package.json dependencies.

### 2. 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.

```
const express = require('express');
const app = express();

// IMPORTANT: webhook route must use raw body
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  // Webhook handling goes here
  res.sendStatus(200);
});

// Other routes use JSON parsing
app.use(express.json());

app.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.

### 3. 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.

```
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  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}`);
  }

  // Handle the event
  res.json({ received: true });
});
```

**Expected result:** Webhook endpoint verifies each incoming event signature. Invalid signatures return 400; valid events return 200.

### 4. 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).

```
switch (event.type) {
  case 'customer.subscription.created':
    const newSub = event.data.object;
    await activateSubscription(newSub.customer, newSub.id, newSub.items.data[0].price.id);
    break;

  case 'customer.subscription.updated':
    const updatedSub = event.data.object;
    await updateSubscription(updatedSub.customer, updatedSub.status, updatedSub.items.data[0].price.id);
    break;

  case 'customer.subscription.deleted':
    const canceledSub = event.data.object;
    await deactivateSubscription(canceledSub.customer);
    break;

  case 'invoice.payment_failed':
    const failedInvoice = event.data.object;
    await handleFailedPayment(failedInvoice.customer, failedInvoice.id);
    break;

  default:
    console.log(`Unhandled event type: ${event.type}`);
}
```

**Expected result:** Each subscription lifecycle event triggers the corresponding business logic in your application.

### 5. 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.

```
# In terminal 1 — forward events to your local server
stripe listen --forward-to localhost:3000/webhook

# In terminal 2 — trigger a test event
stripe trigger customer.subscription.created
```

**Expected result:** Stripe CLI forwards the test event to your local server. Your console logs confirm the event was received and processed.

### 6. 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 code example

File: `server.js`

```javascript
require('dotenv').config();
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

const app = express();
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

// Webhook route — must use raw body for signature verification
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  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}`);
  }

  try {
    switch (event.type) {
      case 'customer.subscription.created': {
        const sub = event.data.object;
        console.log(`New subscription ${sub.id} for customer ${sub.customer}`);
        // TODO: activate user access in your database
        break;
      }
      case 'customer.subscription.updated': {
        const sub = event.data.object;
        console.log(`Subscription ${sub.id} updated — status: ${sub.status}`);
        // TODO: update plan tier, handle trial end, etc.
        break;
      }
      case 'customer.subscription.deleted': {
        const sub = event.data.object;
        console.log(`Subscription ${sub.id} canceled for customer ${sub.customer}`);
        // TODO: revoke user access
        break;
      }
      case 'invoice.payment_failed': {
        const invoice = event.data.object;
        console.log(`Payment failed for invoice ${invoice.id}, customer ${invoice.customer}`);
        // TODO: notify user, flag account
        break;
      }
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }
  } catch (err) {
    console.error('Error processing webhook:', err);
    return res.status(500).json({ error: 'Webhook handler failed' });
  }

  res.json({ received: true });
});

// Other routes use JSON
app.use(express.json());

app.get('/', (req, res) => {
  res.send('Stripe Subscription Webhook Server');
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
```

## Common mistakes

- **Using express.json() for the webhook route instead of express.raw()** — undefined Fix: Stripe signature verification requires the raw request body. Use express.raw({ type: 'application/json' }) specifically for your webhook endpoint.
- **Not returning a 200 response quickly enough** — undefined Fix: Stripe expects a 2xx response within 20 seconds. Process heavy logic asynchronously (e.g., queue the event) and return 200 immediately.
- **Using the test mode webhook secret in production** — undefined Fix: Test and live mode have separate webhook signing secrets. Update your STRIPE_WEBHOOK_SECRET environment variable when deploying to production.
- **Not handling duplicate events** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/stripe-guide/how-to-listen-to-subscription-events-in-stripe
© RapidDev — https://www.rapidevelopers.com/stripe-guide/how-to-listen-to-subscription-events-in-stripe
