After the customer enters their payment details and clicks the Subscribe button, Stripe will handle the payment processing. Whether the payment goes through or not, Stripe will send another request back to our SaaS app telling us what is happening. This request is know as an event in Stripe.
And then in the background, we'll make changes to our databases based on the type of event.
For example, if the event type is customer.subscription.created
, that means the customer has successfully initiated a new subscription, and we should update their role from free user
to paid user
.
To implement this, we need to first tell Stripe where to send the request. Go to your terminal and make sure your development server is already running. Open a new tab and execute the following command:
1stripe listen --forward-to localhost:3001/api/stripe/webhook
If you plan to design the webhook API differently, make sure you update the URL accordingly. After a few seconds of preparation, you should see the following output:
1> Ready! You are using Stripe API Version [2023-10-16]. Your webhook signing secret is whsec_xxx (^C to quit)
Copy the webhook signing secret into your .env
. We are going to need this secret key to ensure the message is indeed sent from Stripe.
.env
1. . .
2
3STRIPE_SECRET_KEY="<stripe_secret_key>"
4STRIPE_PRICE_KEY_MONTH="<price_id>"
5STRIPE_WEBHOOK_SECRET="<webhook_secret>"
Setting up the webhook
Next, you need to set up an API endpoint (webhook) to handle the request:
1src/app/api
2├── auth
3└── stripe
4 ├── cancel
5 ├── monthly
6 │ └── checkout
7 │ └── route.js
8 ├── onetime
9 │ └── checkout
10 ├── resume
11 └── webhook
12 └── route.js <===== Create webhook here
Inside this route.js
, we need to collect three pieces of information, the stripe-signature
from the request header, the STRIPE_SECRET_KEY
from our .env
, and the request body in raw format (buffer or text).
The stripe-signature
and STRIPE_SECRET_KEY
are used to verify the request is indeed sent by Stripe. When putting your app to production later, it is crucial for you to keep the STRIPE_SECRET_KEY
secret.
The raw body contains detailed information regarding the event. It's structure may vary for different types.
api/stripe/webhook/route.js
1import Stripe from "stripe";
2import prisma from "@/libs/db";
3import { headers } from "next/headers";
4
5export async function POST(request) {
6 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8 // Get the Stripe signature
9 const headersList = await headers();
10 const signature = headersList.get("stripe-signature");
11
12 // Getting the raw body
13 const body = await request.text();
14
15 . . .
16}
Of course, we don't have to process these data manually, Stripe offers a stripe.webhooks.constructEvent()
method that helps us to verify the request, parse the information in the raw body, and create an event object that we can read from:
1import Stripe from "stripe";
2import prisma from "@/libs/db";
3import { headers } from "next/headers";
4
5export async function POST(request) {
6 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8 // Get the Stripe signature
9 const headersList = await headers();
10 const signature = headersList.get("stripe-signature");
11
12 // Getting the raw body
13 const body = await request.text();
14
15 // Constructing the event with raw body, signature, and webhook secret
16 const event = stripe.webhooks.constructEvent(
17 body,
18 signature,
19 process.env.STRIPE_WEBHOOK_SECRET
20 );
21
22 . . .
23}
And next, examine the event type, and update the database, send notifications, and perform other tasks accordingly.
For our specific use case, we are only concerned with the scenario where the user successfully starts a subscription, which mean we only need to consider the event customer.subscription.created
. We'll discuss other events in future lessons.
1import Stripe from "stripe";
2import prisma from "@/libs/db";
3import { headers } from "next/headers";
4
5export async function POST(request) {
6 . . .
7
8 switch (event.type) {
9 // Handling subscription creation
10 case "customer.subscription.created": {
11 await prisma.user.update({
12 where: { stripeCustomerId: event.data.object.customer },
13 data: {
14 subscriptionId: event.data.object.id,
15 subscriptionStatus: event.data.object.status,
16 // If event.data.object.status equals "active", role will be assigned "paid user", if not, role will be assigned "free user"
17 role:
18 event.data.object.status === "active" ? "paid user" : "free user",
19 },
20 });
21 break;
22 }
23
24 default:
25 console.warn(`Unhandled event type: ${event.type}`);
26 }
27
28 return new Response("Stripe success", {
29 status: 200,
30 });
31}
In this case, the event is a Subscription object, which contains:
id
: A unique identifier for the subscription.customer
: The ID of the customer who purchased the subscription.status
: Indicating the status of the subscription.
There are many other keys in this Stripe object, please see the linked documentation for details.
Lastly, Stripe requires us to return a 200
response if everything works without an error:
1import Stripe from "stripe";
2import prisma from "@/libs/db";
3import { headers } from "next/headers";
4
5export async function POST(request) {
6 . . .
7
8 switch (event.type) {
9 . . .
10 }
11
12 return new Response("Stripe success", {
13 status: 200,
14 });
15}
Testing the webhook
And now, let's test the webhook and see if everything works. Go back to the pricing page, and click on the Get this plan
button. You will be taken to the Stripe checkout page.
Under Card information, type in 4242 4242 4242 4242
. This is a test card number for Stripe. It only works when your Stripe business account is in the Sandbox.
Click Subscribe, and after a few seconds, the payment will be processed successfully, and you will be directed to the URL you defined in success_url
.
api/stripe/subscribe/monthly/route.js
1import Stripe from "stripe";
2import { auth } from "@/libs/auth";
3
4export async function POST() {
5 // Initialize a new Stripe instance
6 const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
7
8 . . .
9
10 // Create a Stripe checkout session
11 let checkoutSession;
12
13 try {
14 checkoutSession = await stripe.checkout.sessions.create({
15 mode: "subscription",
16 customer: session?.user?.stripeCustomerId,
17 line_items: [
18 {
19 price: process.env.STRIPE_PRICE_KEY_MONTH,
20 quantity: 1,
21 },
22 ],
23
24 success_url: "http://localhost:3001/",
25 cancel_url: "http://localhost:3001/",
26 });
27 } catch (err) {
28 console.log(err);
29 }
30
31 . . .
32}
Go to the terminal, you should see something similar to this:
12025-03-22 12:52:56 --> charge.succeeded [evt_3R5VI1PBIynBBwug01dL87Hf]
22025-03-22 12:52:56 --> checkout.session.completed [evt_1R5VI4PBIynBBwugb3UrC3hD]
32025-03-22 12:52:56 --> payment_method.attached [evt_1R5VI4PBIynBBwugHYCyOyhh]
42025-03-22 12:52:56 --> customer.updated [evt_1R5VI4PBIynBBwugd5y0bdIj]
52025-03-22 12:52:56 --> customer.subscription.created [evt_1R5VI4PBIynBBwugcK5aHxw6]
62025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI4PBIynBBwugb3UrC3hD]
72025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI4PBIynBBwugHYCyOyhh]
82025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI4PBIynBBwugd5y0bdIj]
92025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_3R5VI1PBIynBBwug01dL87Hf]
102025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI4PBIynBBwugcK5aHxw6]
112025-03-22 12:52:56 --> customer.subscription.updated [evt_1R5VI4PBIynBBwugPebJqx5e]
122025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI4PBIynBBwugPebJqx5e]
132025-03-22 12:52:56 --> payment_intent.succeeded [evt_3R5VI1PBIynBBwug0VXs01Yj]
142025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_3R5VI1PBIynBBwug0VXs01Yj]
152025-03-22 12:52:56 --> payment_intent.created [evt_3R5VI1PBIynBBwug0ihvGUUP]
162025-03-22 12:52:56 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_3R5VI1PBIynBBwug0ihvGUUP]
172025-03-22 12:52:57 --> invoice.created [evt_1R5VI5PBIynBBwugQmAQMMjE]
182025-03-22 12:52:57 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI5PBIynBBwugQmAQMMjE]
192025-03-22 12:52:57 --> invoice.finalized [evt_1R5VI5PBIynBBwugDUvu1xhE]
202025-03-22 12:52:57 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI5PBIynBBwugDUvu1xhE]
212025-03-22 12:52:57 --> invoice.updated [evt_1R5VI5PBIynBBwugtm1NinL8]
222025-03-22 12:52:57 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI5PBIynBBwugtm1NinL8]
232025-03-22 12:52:57 --> invoice.paid [evt_1R5VI5PBIynBBwugT4c8mFdM]
242025-03-22 12:52:57 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI5PBIynBBwugT4c8mFdM]
252025-03-22 12:52:57 --> invoice.payment_succeeded [evt_1R5VI5PBIynBBwugA3CnPrk8]
262025-03-22 12:52:57 <-- [200] POST http://localhost:3001/api/stripe/webhook [evt_1R5VI5PBIynBBwugA3CnPrk8]
These logs record the communications between Stripe and your webhook API endpoint. -->
indicates the events sent from Stripe to our API, and <--
indicates the responses sent from our API to Stripe.
Notice that one of the event is customer.subscription.created
, and this is the only one we are concerned with right now, because it indicates the subscription has been started successfully.
api/stripe/webhook/route.js
1import Stripe from "stripe";
2import prisma from "@/libs/db";
3import { headers } from "next/headers";
4
5export async function POST(request) {
6 . . .
7
8 switch (event.type) {
9 // Handling subscription creation
10 case "customer.subscription.created": {
11 await prisma.user.update({
12 where: { stripeCustomerId: event.data.object.customer },
13 data: {
14 subscriptionId: event.data.object.id,
15 subscriptionStatus: event.data.object.status,
16 role:
17 event.data.object.status === "active" ? "paid user" : "free user",
18 },
19 });
20 break;
21 }
22
23 default:
24 console.warn(`Unhandled event type: ${event.type}`);
25 }
26
27 return new Response("Stripe success", {
28 status: 200,
29 });
30}
And finally, go to the dashboard, and the user role should be updated to paid user
now, and the subscription status should be active
.