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.
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.deletedcustomer.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.
STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret_here
STRIPE_SECRET_KEY=sk_live_your_stripe_key_hereStep 3: Install the Stripe SDK
npm install stripeBuilding 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.
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:
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:
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.
// 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.
// 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:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger a test event:
stripe trigger customer.subscription.deletedWhat to Do After Catching a Cancellation
Detecting cancellations is only the first step. Here is what a complete churn-tracking pipeline looks like:
- Record the cancellation— save it to your database with the customer's email, plan, tenure, and any cancellation reason Stripe provides.
- 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.
- 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.
- Build a churn dashboard— track churn rate over time, by plan, by cohort. Identify trends and correlate spikes with product changes or market events.
- 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 →