├── .DS_Store ├── .gitattributes ├── README.md ├── banner.png └── stripe-webhooks.ts /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tego101/nextjs-14-stripe-webhooks/5dc9df027495b8d354271b93fd692dbf9e75df5c/.DS_Store -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NextJS 14: Stripe Webhooks. 2 | ##### _Integrating and Handling Stripe Webhooks in Next.js_ 3 | **by** [Tego](https://x.com/tegodotdev) 4 | 5 | ### Links 6 | **Twitter/X** → [@tegodotdev](https://x.com/tegodotdev) 7 | 8 | **Portfolio** → [tego.dev](https://tego.dev) 9 | 10 | **Tweet:** https://twitter.com/tegocodes/status/1747511299657916604 11 | 12 | #nextjs, #stripe, #stripe-checkout, #subscription, #api, #template, #webhook 13 | 14 | ![stripe_logo](banner.png) 15 | 16 | ## Integrating Stripe webhooks into a Next.js application can be a game-changer, especially for e-commerce platforms. At the same time it can be a nightmare. In this blog post, I'll share a comprehensive guide on setting up a route to handle Stripe Webhooks in Next.js. 17 | 18 | # The Setup 19 | Let's start by cloning the webhook file to your project. Using curl and a few other commands we can quickly get the file cloned over into your project. 20 | 21 | ### MacOS/Unix: 22 | #### Terminal: 23 | ``` 24 | mkdir -p src/app/api/webhooks/billing/stripe 25 | chmod -R 755 src/app/api/webhooks/billing/stripe 26 | curl -o src/app/api/webhooks/billing/stripe/route.ts https://raw.githubusercontent.com/tego101/nextjs-14-stripe-webhooks/main/stripe-webhooks.ts 27 | ``` 28 | 29 | ### Windows PowerShell 😬: 30 | ``` 31 | New-Item -ItemType Directory -Force -Path .\src\app\api\webhooks\billing\stripe 32 | curl -o .\src\app\api\webhooks\billing\stripe\route.ts https://raw.githubusercontent.com/tego101/nextjs-14-stripe-webhooks/main/stripe-webhooks.ts 33 | ``` 34 | 35 | ## Defining Event Types 36 | 37 | Here we will look over the event types in the file and apply our logic. 38 | 39 | ### Here are the event types in our webhook file: 40 | Stripe uses webhooks to notify your server about events that happen in your account, such as successful payments, customer updates, and more. These webhooks payloads have event identifiers, take a look below. 41 | 42 | You can click on them to find out more about each event type. 43 | - [Checkout Session Completed](https://stripe.com/docs/payments/checkout#checkout-session-completed) 44 | - [Checkout Session Async Payment Succeeded](https://stripe.com/docs/payments/checkout#checkout-session-async-payment-succeeded) 45 | - [Checkout Session Async Payment Failed](https://stripe.com/docs/payments/checkout#checkout-session-async-payment-failed) 46 | - [Checkout Session Expired](https://stripe.com/docs/payments/checkout#checkout-session-expired) 47 | - [Charge Succeeded](https://stripe.com/docs/api/charges#charge-succeeded) 48 | - [Charge Failed](https://stripe.com/docs/api/charges#charge-failed) 49 | - [Charge Refunded](https://stripe.com/docs/api/charges#charge-refunded) 50 | - [Charge Expired](https://stripe.com/docs/api/charges#charge-expired) 51 | - [Charge Dispute Created](https://stripe.com/docs/disputes#charge-dispute-created) 52 | - [Charge Dispute Updated](https://stripe.com/docs/disputes#charge-dispute-updated) 53 | - [Charge Dispute Funds Reinstated](https://stripe.com/docs/disputes#charge-dispute-funds-reinstated) 54 | - [Charge Dispute Funds Withdrawn](https://stripe.com/docs/disputes#charge-dispute-funds-withdrawn) 55 | - [Charge Dispute Closed](https://stripe.com/docs/disputes#charge-dispute-closed) 56 | - [Customer Created](https://stripe.com/docs/api/customers#customer-created) 57 | - [Customer Updated](https://stripe.com/docs/api/customers#customer-updated) 58 | - [Customer Deleted](https://stripe.com/docs/api/customers#customer-deleted) 59 | - [Customer Subscription Created](https://stripe.com/docs/api/customers#customer-subscription-created) 60 | - [Customer Subscription Updated](https://stripe.com/docs/api/customers#customer-subscription-updated) 61 | - [Customer Subscription Deleted](https://stripe.com/docs/api/customers#customer-subscription-deleted) 62 | - [Customer Subscription Paused](https://stripe.com/docs/api/customers#customer-subscription-paused) 63 | - [Customer Subscription Resumed](https://stripe.com/docs/api/customers#customer-subscription-resumed) 64 | 65 | ## Handling Events. 66 | For this I highly recommend using [NextJS Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). These serverside functions are game changing and allow you to securely do things like handle billing without exposing routes or info. 67 | 68 | In my case this is how I handled the **"checkout.session.completed"** event. I found the event case in the webhook file and I added my logic to it using [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). 69 | 70 | ``` 71 | import { 72 | getInvoice, 73 | addInvoicePayment, 74 | markInvoicePaid, 75 | getInvoicePayment, 76 | getInvoicePaymentByStripeID, 77 | updateInvoicePayment, 78 | 79 | } from '@/actions/invoice-actions.ts'; 80 | 81 | import { 82 | renewOrCreateService, 83 | } from '@/actions/subscriber-service-actions.ts'; 84 | 85 | case "checkout.session.completed": 86 | if (status !== "paid") { 87 | // TODO: Failed Payment. 88 | 89 | return new Response( 90 | JSON.stringify({ error: "Payment not completed!" }), 91 | { 92 | status: 500, 93 | } 94 | ); 95 | } 96 | 97 | if (meta?.is_service_renewal) { 98 | // TODO: Handle service renewal. 99 | } 100 | 101 | // Find invoicePayment by Stripe Checkout Session ID. 102 | const invoicePayment = await getInvoicePaymentByStripeID(body.data?.object?.client_reference_id.toString()) 103 | 104 | if (!invoicePayment) { 105 | return new Response(JSON.stringify({ error: "Payment not found!" }), { 106 | status: 500, 107 | }); 108 | } 109 | 110 | // Add payment_intent to invoicePayment.meta 111 | const updatedInvoicePayment = await addInvoicePayment({ 112 | status: "PAID", 113 | stripeCheckoutSessionId: id, 114 | stripeCheckoutPaymentIntentId: body.data?.object?.payment_intent ?? null, 115 | stripeCheckoutInvoiceId: stripe_invoice ?? null, 116 | }); 117 | 118 | // Update invoice status to paid 119 | const updateInvoice = await markInvoicePaid( 120 | invoicePayment.invoiceId, 121 | { 122 | stripeCheckoutSessionId: id, 123 | stripeCheckoutInvoiceId: stripe_invoice ?? payment_intent, 124 | }, 125 | }); 126 | 127 | // Activate service and set expiresAt to the plan.duration & plan.durationUnit + now 128 | // If mode is set to subscription, set the stripeSubscriptionId to the subId 129 | // Finally, set the status to ACTIVE 130 | const service = await renewOrCreateService(meta?.serviceId, 131 | { 132 | expiresAt: calculateExpirationDate(invoice?.plan as Plan), 133 | status: "ACTIVE", 134 | stripeSubscriptionId: mode !== "payment" ? subId : null, 135 | meta: { 136 | stripe_invoice: stripe_invoice, 137 | stripe_checkout_session_id: id, 138 | stripe_checkout_invoice_id: stripe_invoice, 139 | stripe_payment_intent: payment_intent, 140 | }, 141 | }, 142 | }); 143 | 144 | // Email the user to let them know their subscription is active. 145 | // Resend sendSubWelcomeEmail() server action here. 146 | return new Response(JSON.stringify({ message: "Payment completed!" }), { 147 | status: 200, 148 | }); 149 | ``` 150 | 151 | In this snippet I update the invoice with the new stripe payment id. I also create a new Invoice Payment and store that aswell, right before I renew or create the subscription for the subscriber. 152 | 153 | ## Go ahead and Explore! 154 | 155 | Inspect the webhook file and add in your server actions or logic you have for your app. I have included a few console.log events in the file so you may get a visual of what data is coming in when the webhook is triggered. 156 | 157 | ### Also, Don't forget to set your environment values: 158 | **STRIPE_WEBHOOK_SECRET** : The secret key ensures the security and integrity of the webhook payloads. 159 | 160 | 161 | **Hope this helps someone,** 162 | -- Tego 163 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tego101/nextjs-14-stripe-webhooks/5dc9df027495b8d354271b93fd692dbf9e75df5c/banner.png -------------------------------------------------------------------------------- /stripe-webhooks.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * app/api/webhooks/billing/stripe/route.ts 3 | * 4 | *================================================= 5 | * Stripe Webhook Route. 6 | *================================================= 7 | * This route is used to handle Stripe Webhooks. 8 | * - https://stripe.com/docs/webhooks 9 | * @author gh/tego101 10 | * @version 1.0.0 11 | * @url https://github.com/tego101/nextjs-14-stripe-webhooks 12 | */ 13 | import Stripe from "stripe"; 14 | import { NextApiRequest, NextApiResponse } from "next"; 15 | 16 | 17 | type EventName = 18 | // Checkout: https://stripe.com/docs/payments/checkout 19 | | "checkout.session.completed" 20 | | "checkout.session.async_payment_succeeded" 21 | | "checkout.session.async_payment_failed" 22 | | "checkout.session.expired" 23 | // Charge: https://stripe.com/docs/api/charges 24 | | "charge.succeeded" 25 | | "charge.failed" 26 | | "charge.refunded" 27 | | "charge.expired" 28 | // Disputes: https://stripe.com/docs/disputes 29 | | "charge.dispute.created" 30 | | "charge.dispute.updated" 31 | | "charge.dispute.funds_reinstated" 32 | | "charge.dispute.funds_withdrawn" 33 | | "charge.dispute.closed" 34 | // Customer: https://stripe.com/docs/api/customers 35 | | "customer.created" 36 | | "customer.updated" 37 | | "customer.deleted" 38 | | "customer.subscription.created" 39 | | "customer.subscription.updated" 40 | | "customer.subscription.deleted" 41 | | "customer.subscription.paused" 42 | | "customer.subscription.resumed"; 43 | 44 | async function handleStripeWebhook(body: any) { 45 | const mode = body.data?.object?.mode; 46 | const id = body.data?.object?.id; 47 | const obj = body.data?.object?.object; 48 | const stat = body.data?.object?.status; 49 | const status = body.data?.object?.payment_status; 50 | const payment_intent = body.data?.object?.payment_intent; 51 | const subId = body.data?.object?.subscription; 52 | const stripeInvoiceId = body.data?.object?.invoice; 53 | const user = body.data?.object?.metadata?.userId; 54 | const meta = body.data?.object?.metadata; 55 | const stripe_invoice = body.data?.object?.invoice; 56 | const type = body.type; 57 | 58 | // console.log everything above REMOVE BEFORE PRODUCTION. 59 | console.log("mode --->", mode); 60 | console.log("webhook type --->", type); 61 | console.log("id --->", id); 62 | console.log("obj --->", obj); 63 | console.log("stat --->", stat); 64 | console.log("status --->", status); 65 | console.log("payment_intent --->", payment_intent); 66 | console.log("subId --->", subId); 67 | console.log("stripeInvoiceId --->", stripeInvoiceId); 68 | console.log("user --->", user); 69 | console.log("meta --->", meta); 70 | console.log("stripe_invoice --->", stripe_invoice); 71 | 72 | // Switch on the event type. 73 | switch (type) { 74 | /* 75 | * =~~~~~~~~~~~~~~~~~~~~~~~= 76 | * Session Expired. 77 | * =~~~~~~~~~~~~~~~~~~~~~~~= 78 | * This is the webhook that is fired when a session expires. 79 | */ 80 | case "checkout.session.expired": 81 | // logic to handle expired sessions. 82 | 83 | return new Response( 84 | JSON.stringify({ message: "Payments marked canceled!" }), 85 | { 86 | status: 200, 87 | } 88 | ); 89 | /* 90 | * =~~~~~~~~~~~~~~~~~~~~~~~= 91 | * Charge: Succeeded. 92 | * =~~~~~~~~~~~~~~~~~~~~~~~= 93 | * This is the webhook that is fired when a payment is successful. 94 | */ 95 | case "charge.succeeded": 96 | // logic to handle successful charges. 97 | 98 | return new Response(JSON.stringify({ message: "Payment completed!" }), { 99 | status: 200, 100 | }); 101 | /* 102 | * =~~~~~~~~~~~~~~~~~~~~~~~= 103 | * Charge: Refunded. 104 | * =~~~~~~~~~~~~~~~~~~~~~~~= 105 | * This is the webhook that is fired when a refund is completed. 106 | */ 107 | case "charge.refunded": 108 | // logic to handle refunded charges. 109 | 110 | return new Response(JSON.stringify({ message: "Refund completed!" }), { 111 | status: 200, 112 | }); 113 | /* 114 | * =~~~~~~~~~~~~~~~~~~~~~~~= 115 | * Charge: Refunded. 116 | * =~~~~~~~~~~~~~~~~~~~~~~~= 117 | * This is the webhook that is fired when a charge fails. 118 | */ 119 | case "charge.failed": 120 | // logic to handle failed charges. 121 | 122 | return new Response(JSON.stringify({ message: "Payment Updated!" }), { 123 | status: 200, 124 | }); 125 | /* 126 | * =~~~~~~~~~~~~~~~~~~~~~~~= 127 | * Charge: Expired. 128 | * =~~~~~~~~~~~~~~~~~~~~~~~= 129 | * This is the webhook that is fired when a charge expires. 130 | */ 131 | case "charge.expired": 132 | // logic to handle expired charges. 133 | 134 | return new Response(JSON.stringify({ message: "Payment Updated!" }), { 135 | status: 200, 136 | }); 137 | /* 138 | * =~~~~~~~~~~~~~~~~~~~~~~~= 139 | * Charge Dispute: Created. 140 | * =~~~~~~~~~~~~~~~~~~~~~~~= 141 | * This is the webhook that is fired when a dispute is created. 142 | */ 143 | case "charge.dispute.created": 144 | // logic here... 145 | 146 | return new Response( 147 | JSON.stringify({ message: "Dispute details added!" }), 148 | { 149 | status: 200, 150 | } 151 | ); 152 | /* 153 | * =~~~~~~~~~~~~~~~~~~~~~~~= 154 | * Charge Dispute: Updated. 155 | * =~~~~~~~~~~~~~~~~~~~~~~~= 156 | * This is the webhook that is fired when a dispute is created. 157 | */ 158 | case "charge.dispute.updated": 159 | // logic here... 160 | 161 | return new Response( 162 | JSON.stringify({ message: "Dispute details updated!" }), 163 | { 164 | status: 200, 165 | } 166 | ); 167 | /* 168 | * =~~~~~~~~~~~~~~~~~~~~~~~= 169 | * Charge Dispute: Funds re-instated. 170 | * =~~~~~~~~~~~~~~~~~~~~~~~= 171 | * This is the webhook that is fired when a dispute\'s funds are re-instated. 172 | */ 173 | case "charge.dispute.funds_reinstated": 174 | // logic here.. 175 | 176 | return new Response( 177 | JSON.stringify({ message: "Dispute details updated!" }), 178 | { 179 | status: 200, 180 | } 181 | ); 182 | /* 183 | * =~~~~~~~~~~~~~~~~~~~~~~~= 184 | * Charge Dispute: Funds withdrawn. 185 | * =~~~~~~~~~~~~~~~~~~~~~~~= 186 | * This is the webhook that is fired when a dispute\'s funds are withdrawn. 187 | */ 188 | case "charge.dispute.funds_withdrawn": 189 | // logic here... 190 | 191 | return new Response( 192 | JSON.stringify({ message: "Dispute details updated!" }), 193 | { 194 | status: 200, 195 | } 196 | ); 197 | /* 198 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 199 | * Customer: Created 200 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 201 | * This is the webhook that is fired when a new customer is created. 202 | */ 203 | case "customer.created": 204 | // Add logic for handling customer creation 205 | return new Response(JSON.stringify({ message: "Customer created!" }), { 206 | status: 200, 207 | }); 208 | 209 | /* 210 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 211 | * Customer: Updated 212 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 213 | * This is the webhook that is fired when a customer's details are updated. 214 | */ 215 | case "customer.updated": 216 | // Add logic for handling customer updates 217 | return new Response(JSON.stringify({ message: "Customer updated!" }), { 218 | status: 200, 219 | }); 220 | 221 | /* 222 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 223 | * Customer: Deleted 224 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 225 | * This is the webhook that is fired when a customer is deleted. 226 | */ 227 | case "customer.deleted": 228 | // Add logic for handling customer deletion 229 | return new Response(JSON.stringify({ message: "Customer deleted!" }), { 230 | status: 200, 231 | }); 232 | 233 | /* 234 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 235 | * Customer Subscription: Created 236 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 237 | * This is the webhook that is fired when a customer's subscription is created. 238 | */ 239 | case "customer.subscription.created": 240 | // Add logic for handling the creation of a customer subscription 241 | return new Response( 242 | JSON.stringify({ message: "Customer subscription created!" }), 243 | { 244 | status: 200, 245 | } 246 | ); 247 | 248 | /* 249 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 250 | * Customer Subscription: Updated 251 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 252 | * This is the webhook that is fired when a customer's subscription is updated. 253 | */ 254 | case "customer.subscription.updated": 255 | // Add logic for handling updates to a customer's subscription 256 | return new Response( 257 | JSON.stringify({ message: "Customer subscription updated!" }), 258 | { 259 | status: 200, 260 | } 261 | ); 262 | 263 | /* 264 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 265 | * Customer Subscription: Deleted 266 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 267 | * This is the webhook that is fired when a customer's subscription is deleted. 268 | */ 269 | case "customer.subscription.deleted": 270 | // Add logic for handling the deletion of a customer's subscription 271 | return new Response( 272 | JSON.stringify({ message: "Customer subscription deleted!" }), 273 | { 274 | status: 200, 275 | } 276 | ); 277 | 278 | /* 279 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 280 | * Customer Subscription: Paused 281 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 282 | * This is the webhook that is fired when a customer's subscription is paused. 283 | */ 284 | case "customer.subscription.paused": 285 | // Add logic for handling the pausing of a customer's subscription 286 | return new Response( 287 | JSON.stringify({ message: "Customer subscription paused!" }), 288 | { 289 | status: 200, 290 | } 291 | ); 292 | 293 | /* 294 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 295 | * Customer Subscription: Resumed 296 | * =~~~~~~~~~~~~~~~~~~~~~~~~= 297 | * This is the webhook that is fired when a customer's subscription is resumed. 298 | */ 299 | case "customer.subscription.resumed": 300 | // Add logic for handling the resumption of a customer's subscription 301 | return new Response( 302 | JSON.stringify({ message: "Customer subscription resumed!" }), 303 | { 304 | status: 200, 305 | } 306 | ); 307 | 308 | /* 309 | * =~~~~~~~~~~~~~~~~~~~~~~~= 310 | * Default 311 | * =~~~~~~~~~~~~~~~~~~~~~~~= 312 | */ 313 | default: 314 | return new Response(JSON.stringify({ error: "Invalid event type" }), { 315 | status: 400, 316 | }); 317 | } 318 | } 319 | 320 | async function POST(request: Request) { 321 | try { 322 | // Request Body. 323 | const rawBody = await request.text(); 324 | const body = JSON.parse(rawBody); 325 | 326 | let event; 327 | 328 | // Verify the webhook signature 329 | try { 330 | const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET; 331 | if (!stripeWebhookSecret) { 332 | throw new Error("STRIPE_WEBHOOK_SECRET not set"); 333 | } 334 | 335 | const sig = request.headers.get("Stripe-Signature"); 336 | if (!sig) { 337 | throw new Error("Stripe Signature missing"); 338 | } 339 | 340 | // Assuming you have a Stripe instance configured 341 | event = Stripe.webhooks.constructEvent(rawBody, sig, stripeWebhookSecret); 342 | } catch (err) { 343 | console.error(`⚠️ Webhook signature verification failed.`, err.message); 344 | return new Response( 345 | JSON.stringify({ error: "Webhook signature verification failed" }), 346 | { 347 | status: 400, 348 | } 349 | ); 350 | } 351 | 352 | const webhookResponse = await handleStripeWebhook(event); // Ensure handleStripeWebhook is properly implemented 353 | 354 | return new Response(webhookResponse?.body, { 355 | status: webhookResponse?.status || 200, 356 | }); 357 | } catch (error) { 358 | console.error("Error in Stripe webhook handler:", error); 359 | return new Response(JSON.stringify({ error: "Webhook handler failed." }), { 360 | status: 500, // Changed to 500, indicating a server error 361 | }); 362 | } 363 | } 364 | 365 | async function GET(request: NextApiRequest, response: NextApiResponse) { 366 | // Bad Request or how ever you want to respond. 367 | return response.status(400).json({ error: "Bad Request" }); 368 | } 369 | 370 | export { POST, GET }; 371 | --------------------------------------------------------------------------------