Webhooks and Stripe CLI
Course: Integrate Stripe Subscriptions with Next.js
Introduction
When a customer completes checkout or changes their subscription, Stripe sends your server a webhook event. In production, we have a live website for Stripe to send these events to. But in development, we don't have a publicly accessible site, so we need Stripe CLI to forward those events to our local server.
Set Up Stripe CLI
To install Stripe CLI, you can go to this link: https://docs.stripe.com/stripe-cli
They have very comprehensive instructions for how to install Stripe CLI depending on your machine. You'll be able to check to see if you have Stripe CLI installed by running the following in your terminal:
stripe version
After Stripe CLI has been installed, you'll need to log in, so just run this in your terminal:
stripe login
It will give you a link to visit. Go and click the link to log in.
Next we'll need to do is create a webhook secret by running this in the terminal:
stripe listen --forward-to localhost:3000/api/stripe/webhook
It will give you a secret, go and add that to the .env file:
STRIPE_WEBHOOK_SECRET=whsec_12345…
The secret is to ensure that it's actually Stripe that is hitting our API endpoint and not anyone else.
Creating the Webhook Endpoint
With the webhook secret in place and Stripe CLI set up, we can work on the webhook API endpoint. Go and add the following code to the route.ts file in /api/stripe/webhook/:
import { NextRequest, NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
import { db } from "@/lib/db"
import Stripe from "stripe"
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(request: NextRequest) {
const body = await request.text()
const signature = request.headers.get("stripe-signature")!
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 })
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.userId
const priceId = session.metadata?.priceId
if (userId && priceId) {
// Get subscription details
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
console.log(JSON.stringify(subscription, null, 2))
// Update user with subscription details
await db.user.update({
where: { id: userId },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
stripeCurrentPeriodEnd: new Date(
subscription.items.data[0].current_period_end * 1000
),
plan: priceId === "premium" ? "premium" : "pro",
},
})
}
break
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
// Find user by Stripe customer ID
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId },
})
if (user) {
await db.user.update({
where: { id: user.id },
data: {
stripeCurrentPeriodEnd: new Date(
subscription.items.data[0].current_period_end * 1000
),
plan:
subscription.status === "active"
? user.stripePriceId === "premium"
? "premium"
: "pro"
: "free",
},
})
}
break
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
// Find user by Stripe customer ID
const user = await db.user.findFirst({
where: { stripeCustomerId: customerId },
})
if (user) {
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
plan: "free",
},
})
}
break
}
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
} catch (error) {
console.error("Error processing webhook:", error)
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 500 }
)
}
}
Breaking down the code:
Read Raw Body and Verify Signature
We read the raw request body and get the stripe signature in the header. We then verify the information using the constructEvent method provided by Stripe to ensure that the webhook event is actually from Stripe. It takes 3 parameters, the request body, the signature, and the webhook secret. If the signatures match, it'll return a verified event object, if not then it'll throw an error, which we'll catch.
Handle Events By Type
There are a few different types of events (as you can see when you hover over "type"), but the following are the ones we'll be listening for:
checkout.session.completed
This is fired when a checkout session is completed (customer goes through the payment flow and confirms payment). For a subscription, this means the first payment was received.
We extract the session from the event, and then the metadata from the session. Then we go and retrieve the subscription from Stripe. This will return a subscription object, you can read more about it on the Stripe Documentation.
The main values we're looking for is the subscription ID, price ID, and current period end. You can add additional values if you like. We then update the user in the database with this information.
customer.subscription.updated
This is fired whenever a subscription changes after it has been created. This includes when a customer upgrades or downgrades a plan, or when the billing cycle renews.
When that happens, we'll get the subscription and extract the customerId. We'll find the customer in the database and update their account information.
customer.subscription.deleted
This event is fired when a subscription is canceled or expires. When this happens, we'll go into the database and update the user information to reflect it.
Now with this complete we can test out our subscription logic. Clicking on a plan should take the user to the payment portal in Stripe. If the payment is successful, you'll find that the users plan has been upgraded, as well as a purchase in your Stripe dashboard.