Technical Guide

Stripe Cancellation Webhook Guide: Track Churned Customers

A step-by-step technical guide to catching Stripe subscription cancellations with webhooks, handling edge cases, and turning cancellation events into actionable churn data.

·15 min read

What Are Stripe Webhooks?

Webhooks are HTTP callbacks that Stripe sends to your server when something happens in your Stripe account. Instead of polling the Stripe API to check for changes, you register a URL and Stripe pushes events to it in real time.

When a customer cancels their subscription, Stripe fires webhook events that contain all the details about the cancellation: who cancelled, what plan they were on, when the subscription ends, and whether it was an immediate cancellation or an end-of-period one.

Webhooks are essential for tracking churn because they give you real-time notification of cancellations. Without them, you would need to periodically check every subscription in your Stripe account to see if any have been cancelled—an approach that is both slow and expensive in API calls.

Stripe Cancellation Events Explained

Stripe has several events related to subscription lifecycle, but for tracking churn, you need to understand three key events:

customer.subscription.updated

This event fires when a subscription is modified, including when a customer initiates cancellation with cancel_at_period_end = true. At this point, the subscription is still active—the customer has signaled intent to cancel but their access continues until the current billing period ends.

This is your early warning. The customer has decided to leave, but you still have days or weeks to intervene.

customer.subscription.deleted

This event fires when the subscription actually ends. If the customer set cancel_at_period_end, this fires at the end of the billing period. If they cancelled immediately, it fires right away. The subscription status will be canceled.

This is the definitive churn event. When you see this, the customer has lost access and their subscription is gone.

invoice.payment_failed

This event fires when a payment attempt fails. After multiple failed attempts (configurable in Stripe's retry settings), the subscription may be cancelled automatically. This represents involuntary churn—the customer did not choose to leave, but their payment method stopped working.

Which event should you listen to?

For churn tracking, listen to customer.subscription.deleted as your primary event. Optionally, also listen to customer.subscription.updated for early warning when cancel_at_period_end changes to true.

Setting Up Your Webhook Endpoint

Step 1: Create the endpoint in Stripe Dashboard

Go to the Stripe Dashboard → Developers → Webhooks and click “Add endpoint.” Enter your endpoint URL (for example, https://yourapp.com/api/webhooks/stripe) and select the events you want to receive.

For churn tracking, select at minimum:

  • customer.subscription.deleted
  • customer.subscription.updated

Step 2: Copy your webhook signing secret

After creating the endpoint, Stripe provides a signing secret (starts with whsec_). Store this in your environment variables—you will need it to verify that incoming webhook requests are genuinely from Stripe and not forged by an attacker.

.env.local
STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret_here
STRIPE_SECRET_KEY=sk_live_your_stripe_key_here

Step 3: Install the Stripe SDK

Terminal
npm install stripe

Building the Webhook Handler in Next.js

Here is a complete webhook handler for a Next.js app using the App Router. This handler verifies the Stripe signature, parses the event, and processes subscription cancellations.

app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server"
import Stripe from "stripe"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = req.headers.get("stripe-signature")

  if (!signature) {
    return NextResponse.json(
      { error: "Missing stripe-signature header" },
      { status: 400 }
    )
  }

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      webhookSecret
    )
  } catch (err) {
    console.error("Webhook signature verification failed:", err)
    return NextResponse.json(
      { error: "Invalid signature" },
      { status: 400 }
    )
  }

  switch (event.type) {
    case "customer.subscription.deleted": {
      const subscription = event.data
        .object as Stripe.Subscription
      await handleSubscriptionCancelled(subscription)
      break
    }

    case "customer.subscription.updated": {
      const subscription = event.data
        .object as Stripe.Subscription
      if (subscription.cancel_at_period_end) {
        await handleCancellationScheduled(subscription)
      }
      break
    }

    default:
      // Unhandled event type
      break
  }

  // Always return 200 to acknowledge receipt
  return NextResponse.json({ received: true })
}

Now implement the handler functions. Here is what a basic cancellation handler looks like:

Cancellation handler functions
async function handleSubscriptionCancelled(
  subscription: Stripe.Subscription
) {
  // Fetch the customer to get their email
  const customer = await stripe.customers.retrieve(
    subscription.customer as string
  )

  if (customer.deleted) return

  const cancellationData = {
    customerId: customer.id,
    customerEmail: customer.email,
    customerName: customer.name,
    subscriptionId: subscription.id,
    planId: subscription.items.data[0]?.price.id,
    cancelledAt: new Date(
      (subscription.canceled_at ?? 0) * 1000
    ),
    reason: subscription.cancellation_details
      ?.reason ?? "unknown",
    feedback: subscription.cancellation_details
      ?.comment ?? null,
  }

  // Save to your database
  await db.cancellations.create({
    data: cancellationData,
  })

  // Send exit email (or let ChurnNote handle this)
  await sendExitEmail(cancellationData)

  console.log(
    "Cancellation recorded:",
    cancellationData.customerEmail
  )
}

async function handleCancellationScheduled(
  subscription: Stripe.Subscription
) {
  // The customer set cancel_at_period_end = true
  // This is your early warning to intervene
  const customer = await stripe.customers.retrieve(
    subscription.customer as string
  )

  if (customer.deleted) return

  console.log(
    "Cancellation scheduled for:",
    customer.email,
    "at",
    new Date(
      (subscription.current_period_end) * 1000
    )
  )

  // Optionally trigger a retention flow:
  // - Send a "we're sorry to see you go" email
  // - Offer a discount or plan change
  // - Notify your CS team
}

Important: Disable body parsing

Stripe webhook signature verification requires the raw request body. In Next.js App Router, req.text() gives you the raw body, so no additional configuration is needed. If you are using the Pages Router, you need to disable the default body parser:

pages/api/webhooks/stripe.ts (Pages Router only)
export const config = {
  api: {
    bodyParser: false,
  },
}

Edge Cases and Gotchas

Webhook handling looks simple in tutorials but has several edge cases that trip up teams in production. Here are the ones to watch for:

1. Duplicate events

Stripe may send the same event more than once. Your handler must be idempotent—processing the same event twice should not create duplicate records or send duplicate emails. Use the event ID (event.id) as a deduplication key.

Idempotency check
// Check if we've already processed this event
const existing = await db.webhookEvents.findUnique({
  where: { stripeEventId: event.id },
})

if (existing) {
  return NextResponse.json({ received: true })
}

// Record the event before processing
await db.webhookEvents.create({
  data: { stripeEventId: event.id },
})

2. Out-of-order events

Stripe does not guarantee event ordering. You might receive customer.subscription.deleted before customer.subscription.updated. Design your handler to be order-independent. Use timestamps from the event data (not arrival time) to determine the true sequence of events.

3. Subscription reactivation

A customer can cancel (set cancel_at_period_end = true) and then reactivate before the period ends. If you listen to the updated event for early warning, also check for reactivation to avoid sending unnecessary exit emails.

4. Trial expirations vs. cancellations

When a trial expires without converting to a paid subscription, Stripe fires customer.subscription.deleted. This is technically churn, but you may want to handle it differently from a paying customer who cancels. Check the subscription's status and trial_end fields to distinguish.

5. Webhook timeouts

Stripe expects a 2xx response within 20 seconds. If your handler takes longer (for example, because you are sending an email synchronously), the webhook will time out and Stripe will retry. Move slow operations to a background queue.

Queue slow work
// Don't do this in the webhook handler:
// await sendExitEmail(data) // might take 5+ seconds

// Instead, queue it:
await queue.add("send-exit-email", {
  cancellationData,
})

// Return 200 immediately
return NextResponse.json({ received: true })

6. Testing locally

Use the Stripe CLI to forward webhook events to your local development server:

Terminal
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger a test event:
stripe trigger customer.subscription.deleted

What to Do After Catching a Cancellation

Detecting cancellations is only the first step. Here is what a complete churn-tracking pipeline looks like:

  1. Record the cancellation— save it to your database with the customer's email, plan, tenure, and any cancellation reason Stripe provides.
  2. Send an exit email— within minutes, send a personalized email asking why they left. Plain text from a real person performs best. Avoid surveys with 10 questions; a simple “What could we have done better?” gets more responses.
  3. Categorize the response— when they reply, categorize the reason (price, missing feature, switched to competitor, no longer needed, etc.). This turns individual feedback into aggregate data you can act on.
  4. Build a churn dashboard— track churn rate over time, by plan, by cohort. Identify trends and correlate spikes with product changes or market events.
  5. Act on the feedback— use the categorized reasons to prioritize product improvements. If 40% of churned customers cite a missing integration, that integration should be high on your roadmap.

Use our churn rate calculator to monitor your numbers, and check churn benchmarks to see how you compare.

Skip the Plumbing: Automate with ChurnNote

Building and maintaining a webhook handler, exit email system, response categorizer, and churn dashboard is a significant engineering investment. You need to handle all the edge cases above, build email infrastructure, implement AI categorization, and maintain it all as Stripe's API evolves.

ChurnNote does all of this out of the box. Connect your Stripe account, and ChurnNote automatically listens for cancellation events, sends personalized exit emails, categorizes responses using AI, and provides a dashboard with churn analytics. Setup takes about 5 minutes.

The webhook handling code above is exactly what ChurnNote runs under the hood—but with years of battle-testing against all the edge cases. If you want to build it yourself, this guide gives you a solid starting point. If you want to skip to the insights, ChurnNote is the faster path.

Track Stripe cancellations in 5 minutes

Connect your Stripe account to ChurnNote and start getting feedback from churned customers today. No webhook code required.

Start free trial →

Related guides & tools