stripe
Stripe Payment Links
Stripe Checkout Sessions
Stripe checkout sessions are ways to dynamically build checkout carts for a customer by building a server and sending product information about the products in the cart to stripe. Stripe then redirects the user to its own payment page to pay for the products.
Stripe config
The first thing to do is to initialize a stripe instance like so, using the secret api key from the stripe dashboard.
import { Stripe } from "npm:stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
Products in stripe
Products in stripe can be created manually in the stripe dashboard or dynamically. Here are the use cases for both:
- manual creation: Best used for product or subscription that is fixed, i.e., selling a fixed digital product or service like online colab coding notebook.
- dynamic creation: Best used for products or subscriptions with add-ons and customizations that are best suited for creating a product at runtime.
When creating a product manually, the most important things to note are its price id and product id, which help you get information about the product during the checkout or during webhooks.
- price id: contains information about the purchase, like tax info, price point, etc.
- product: contains information about the product, like name, description, images, and any metadata you set on the product itself from the stripe dashboard.

You can easily fetch products and prices through the stripe SDK:
async function getProductById(productId: string) {
return await stripe.products.retrieve(productId);
}
async function getPriceById(priceId: string) {
return await stripe.prices.retrieve(priceId);
}
Checkout with products
The stripe.checkout.sessions.create() method redirects the client side to the stripe checkout page to buy a subscription or one-time product. Here are the options you can pass in to configure the checkout experience:
payment_method_types: Astring[]value of the different types of payment options to make available in the checkout page.mode:"payment"for one-time payments and"subscription"if buying a subscription.cancel_url: the URL of your server to redirect to if the user cancels the payment.success_url: the URL of your server to redirect to if the user successfully goes through with the payment.metadata: an object of typeRecord<string, string>that allows you to pass data through and retrieve it with webhooks. This is useful for passing data like who initiated the purchase and any other stuff.
The most important option here is line_items, which is an array of the different products to buy in the checkout cart. There are three different ways to register products for a checkout cart here:
- Register via
priceIdof a product - Register via the product id to the
price_data.productkey - Dynamically create a new product with the
price_data.product_datakey
async function checkout() {
const response = await this.stripe.checkout.sessions.create({
payment_method_types: ["card", "cashapp", "link"],
mode: "payment", // for one-time payment
cancel_url: "http://localhost:8000/checkout/cancel",
success_url: "http://localhost:8000/checkout/success",
line_items: [
// example of registering product from price id
{
priceId: "price_1RCEhz4h8gpbjBP8iYDV1X8L",
quantity: 1
},
// example of registering product from product id
{
quantity: this.quantity,
price_data: {
product: this.productId,
currency: "usd",
},
},
// example of registering product dynamically
{
quantity: this.quantity,
price_data: {
currency: "usd",
product_data: {
name: "Buggati",
images: ["https://...."],
description: "Super fancy car",
metadata: {
payload: JSON.stringify({
instructionsLink: "https://..."
})
},
},
// Stripe requires the amount in cents
unit_amount: this.priceInDollars * 100,
},
}
],
// any metadata to pass so you can access in webhooks
metadata: {
payload: JSON.stringify({ userId: "waadliadsfas@mail.com" })
},
});
const checkoutPageUrl = response.url;
}
Once you await the checkout session you will get back a response, with the most important thing being the unique stripe checkout page url located on response.url. This is the page we want to redirect our users to.
In summary, here is the flow:
- From the frontend, when the user is ready to purchase, make a request to our server at some arbitrary route like
/stripe/checkout, and await the response. - In that route, create a checkout session with the
stripe.checkout.sessions.create()method and get back the checkout page URL and return it to the frontend. - After getting back the response, manually navigate from the frontend to that checkout page link.
async function submitCart() {
const response = await fetch("/stripe/checkout", {
method: "POST"
});
const data = await response.json();
window.location.href = data.redirectUrl as string;
}
Checkout with subscriptions
to checkout with subscriptions, you use the same stripe checkout API but this time you'll do two different things:
- Use a product/price that references a recurring subscription
- change the
modeto"subscription" - Use webhooks to listen for customer subscription created, updated, deleted
- Handle billing portal for users to cancel their subscription.
The main difference for subscriptions is that Stripe creates a customer object in the backend, and a subscription is always tied to a customer.
IMPORTANT
It is extremely important that you limit customers to only one subscription in stripe. To do this, go to the settings here to enable that: https://dashboard.stripe.com/settings/checkout#subscriptions
creating the subscription
The stripe.checkout.sessions.create() method works the same for subscription but we have to change a few things in the options object:
mode: set this to"subscription"for recurring paymentscustomer_email: you must provide the customer's email so that a customer object is created behind the scenes in stripe.
Then instead of getting back a url, you can get back a session id from the session.id object and pass that to the frontend to redirect to the subscription checkout page.
import { NextResponse } from 'next/server';
import { stripe } from '@/utils/stripe';
export async function POST(request: Request) {
try {
const { priceId, email, userId } = await request.json();
const session = await stripe.checkout.sessions.create({
metadata: {
user_id: userId,
},
customer_email: email,
payment_method_types: ['card'],
line_items: [
{
// base subscription
price: priceId,
},
{
// one-time setup fee
price: 'price_1OtHdOBF7AptWZlcPmLotZgW',
quantity: 1,
},
],
mode: 'subscription',
success_url: `${request.headers.get('origin')}/success`,
cancel_url: `${request.headers.get('origin')}/cancel`,
});
return NextResponse.json({ id: session.id });
} catch (error: any) {
console.error(error);
return NextResponse.json({ message: error.message }, { status: 500 });
}
}
Now in the frontend, follow these steps:
- load the client-side stripe library
- fetch your create subscription endpoint and retrieve the session id
- use the
stripe.redirectToCheckout()method, passing in the session id to redirect the user to the subscription checkout page.
import { loadStripe } from '@stripe/stripe-js';
async function goToCheckout() {
// 1. load stripe
const stripe = await loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
// 2. fetch create subscription endpoint, which returns session
const response = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId: 'price_1OtHkdBF7AptWZlcIjbBpS8r', userId: data.user?.id, email: data.user?.email }),
});
const session = await response.json();
// 3. redirect via session id
await stripe?.redirectToCheckout({ sessionId: session.id });
}
webhooks
In your webhooks, these are the 4 events you'll want to listen for:
"checkout.session.completed": the user buys the subscription"customer.subscription.created": the subscription is created and becomes active."customer.subscription.updated": the subscription is updated, like is set for cancellation or something else."customer.subscription.deleted": the subscription is cancelled - the user is no longer subscribed.
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/utils/stripe';
import { supabaseAdmin } from '@/utils/supabaseServer';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
try {
const rawBody = await request.text();
const signature = request.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (error: any) {
console.error(`Webhook signature verification failed: ${error.message}`);
return NextResponse.json({ message: 'Webhook Error' }, { status: 400 });
}
// Handle the checkout.session.completed event
if (event.type === 'checkout.session.completed') {
const session: Stripe.Checkout.Session = event.data.object;
console.log(session);
const userId = session.metadata?.user_id;
// Create or update the stripe_customer_id in the stripe_customers table
const { error } = await supabaseAdmin
.from('stripe_customers')
.upsert({
user_id: userId,
stripe_customer_id: session.customer,
subscription_id: session.subscription,
plan_active: true,
plan_expires: null
})
}
if (event.type === 'customer.subscription.updated') {
}
if (event.type === 'customer.subscription.deleted') {
}
return NextResponse.json({ message: 'success' });
} catch (error: any) {
return NextResponse.json({ message: error.message }, { status: 500 });
}
}
Billing portal
The billing portal is just a stripe-managed URL you can redirect the user to. Use the stripe.billingPortal.sessions.create() method, passing in the customer id and the return url to grab a billing portal session and the url off of that.
You can then manually redirect the user to the billing portal URL.
import { stripe } from "@/utils/stripe";
export async function createPortalSession(customerId: string) {
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `http://localhost:3000`,
});
return { id: portalSession.id, url: portalSession.url };
}
Checkout sessions with payment links
You can take advantage of prebuilt payment links if you have static products that don't change, and then just handle app logic with webhooks instead. These payment links link directly to a checkout session that stripe will create for you, and you don't need a return URL.
- Go to stripe, on a product you made, create a payment link for it. Copy that link.
- When the user wants to pay, redirect them to the payment link.
- Setup webhook listeners to know when a new customer in stripe was created, a product was bought, an invoice made, etc., and there implement your database logic to change the user to pro or say they have bought something.
Embedded checkout sessions
embedded checkout sessions give you the flexibility of handling payments through your UI without users being redirected to Stripe. It offers a better user experience and more customization of how the payment looks like.
There are a few steps to follow:
- Create a POST handler on your server to create the stripe payment
- Create a checkout session as usual but with
ui_modeproperty set to"embedded"to enable embedded payments in stripe. - From the checkout session, get the session id and the session client secret and serve that as the response.
- Create a checkout session as usual but with
- In the frontend, use the Stripe client SDK to render stripe provided components, passing the client secret and session id.
- In your backend, handle the return URL and optionally check the session status to see if the payment went through.
step 1
When the frontend calls this API route, create embedded checkout session through ui_mode: "embedded" and return the checkout session URL to redirect to.
For extra security and to unique identify a user based on their payment session, we pass a session_id= query param.
import { NextResponse } from 'next/server';
import { stripe } from '@/utils/stripe';
export async function POST(request: Request) {
try {
const { priceId } = await request.json();
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
},
],
mode: 'subscription',
// make sure to handle this later
return_url: `${request.headers.get('origin')}/return?session_id={CHECKOUT_SESSION_ID}`,
});
// must return session id and client secret
return NextResponse.json({ id: session.id, client_secret: session.client_secret });
} catch (error: any) {
console.error(error);
return NextResponse.json({ message: error.message }, { status: 500 });
}
}
step 2
Render the <EmbeddedCheckout /> component from within the <EmbeddedCheckoutProvider /> provider.
"use client";
import { loadStripe } from "@stripe/stripe-js";
import {
EmbeddedCheckoutProvider,
EmbeddedCheckout,
} from "@stripe/react-stripe-js";
import { useCallback, useRef, useState } from "react";
export default function EmbeddedCheckoutButton() {
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
const [showCheckout, setShowCheckout] = useState(false);
const modalRef = useRef<HTMLDialogElement>(null);
const fetchClientSecret = useCallback(() => {
// Create a Checkout Session
return fetch("/api/embedded-checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId: "price_1OtHkdBF7AptWZlcIjbBpS8r" }),
})
.then((res) => res.json())
.then((data) => data.client_secret);
}, []);
const options = { fetchClientSecret };
const handleCheckoutClick = () => {
setShowCheckout(true);
modalRef.current?.showModal();
};
const handleCloseModal = () => {
setShowCheckout(false);
modalRef.current?.close();
};
return (
<div id="checkout" className="my-4">
<button className="btn" onClick={handleCheckoutClick}>
Open Modal with Embedded Checkout
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box w-100 max-w-screen-2xl">
<h3 className="font-bold text-lg">Embedded Checkout</h3>
<div className="py-4">
{showCheckout && (
<EmbeddedCheckoutProvider stripe={stripePromise} options={options}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
)}
</div>
<div className="modal-action">
<form method="dialog">
<button className="btn" onClick={handleCloseModal}>
Close
</button>
</form>
</div>
</div>
</dialog>
</div>
);
}
step 3
Handle return URL functionality by checking for the session id.
import { stripe } from "@/utils/stripe";
async function getSession(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId!);
return session;
}
export default async function CheckoutReturn({ searchParams }) {
const sessionId = searchParams.session_id;
const session = await getSession(sessionId);
console.log(session);
if (session?.status === "open") {
return <p>Payment did not work.</p>;
}
if (session?.status === "complete") {
return (
<h3>
We appreciate your business! Your Stripe customer ID is:
{(session.customer as string)}.
</h3>
);
}
return null;
}
Stripe checkout elements
Instead of redirecting to an external stripe checkout page or even just showing an embedded checkout page, you can import individual stripe prebuilt components and hook them up to payments.
since checkout elements, our client side will need to take advantage of a payment, intent, passing a client secret and an femoral key to enable payments from the client side.
step 1: create payment intent
The first step is to set up a an API route that createw a payment intent and sends it to the frontend.
import { NextRequest, NextResponse } from "next/server";
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
export async function POST(request: NextRequest) {
try {
const { amount } = await request.json();
const paymentIntent = await stripe.paymentIntents.create({
amount: amount,
currency: "usd",
automatic_payment_methods: { enabled: true },
});
return NextResponse.json({ clientSecret: paymentIntent.client_secret });
} catch (error) {
console.error("Internal Error:", error);
// Handle other errors (e.g., network issues, parsing errors)
return NextResponse.json(
{ error: `Internal Server Error: ${error}` },
{ status: 500 }
);
}
}
step 2: create payment element
To hook up a payment element on the client side and handle payments client side securely, we need to use the client secret and ephemeral key for payments.
Across all client side payment element implementations, you'll have standard React things to implement:
- Init stripe with the
useStripe()hook - Init stripe elements with the
useElements()hook
const CheckoutPage = () => {
const stripe = useStripe();
const elements = useElements();
// ...
}
- Fetch the client secret from your API route, creating a payment intent you hope to fulfill through the user checking out via the payment element.
- If client secret is available, render payment element, which should be nested inside a
<form> element - On the form
onSubmithandler, just run something like this:
async function finishPaymentIntent(elements, clientSecret) {
const { error: submitError } = await elements.submit();
if (submitError) throw new Error("payment did not go through")
// runs the payment and redirects to return url
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `http://www.localhost:3000/payment-success`,
},
});
if (error) throw new Error("payment did not go through")
}
"use client";
import React, { useEffect, useState } from "react";
import {
useStripe,
useElements,
PaymentElement,
} from "@stripe/react-stripe-js";
async function fetchClientSecret() {
const response = await fetch("/api/create-payment-intent", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ amount: 799 }),
})
const { clientSecret } = await response.json()
return clientSecret
}
const CheckoutPage = () => {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string>();
const [clientSecret, setClientSecret] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchClientSecret().then(secret => setClientSecret(secret))
}, []);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMessage(submitError.message);
setLoading(false);
return;
}
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `http://www.localhost:3000/payment-success`,
},
});
if (error) {
// This point is only reached if there's an immediate error when
// confirming the payment. Show the error to your customer (for example, payment details incomplete)
setErrorMessage(error.message);
} else {
// The payment UI automatically closes with a success animation.
// Your customer is redirected to your `return_url`.
}
setLoading(false);
};
if (!clientSecret || !stripe || !elements) {
return <p>Loading</p>
}
return (
<form onSubmit={handleSubmit} className="bg-white p-2 rounded-md">
{clientSecret && <PaymentElement />}
{errorMessage && <div>{errorMessage}</div>}
<button
disabled={!stripe || loading}
className="text-white w-full p-5 bg-black mt-2 rounded-md font-bold disabled:opacity-50 disabled:animate-pulse"
>
{!loading ? `Pay $${amount}` : "Processing..."}
</button>
</form>
);
};
export default CheckoutPage;
Here's a complete hook to cover the use case:
import { useStripe, useElements } from "@stripe/react-stripe-js";
import { useState } from "react";
export const useStripeClient = (route: string) => {
const stripe = useStripe();
const elements = useElements();
const [isLoading, setIsLoading] = useState(false);
async function fetchClientSecret(body: Record<string, any>) {
const response = await fetch(`/api/${route}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const { clientSecret } = await response.json();
if (!clientSecret) throw new Error("client secret not found");
return clientSecret as string;
}
async function finishPaymentIntent(clientSecret: string) {
if (!elements || !stripe) throw new Error("elements not found");
const { error: submitError } = await elements.submit();
if (submitError) throw new Error("payment did not go through");
// runs the payment and redirects to return url
const { error } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `http://www.localhost:3000/payment-success`,
},
});
if (error) throw new Error("payment did not go through");
}
async function executePayment(clientSecret: string) {
setIsLoading(true);
try {
await finishPaymentIntent(clientSecret);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
}
return {
isLoading,
executePayment,
fetchClientSecret,
};
};
Stripe CLI
The Stripe CLI is an easy way to test out webhooks locally. here is how to install:
Installation
windows
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe
mac
brew install stripe/stripe-cli/stripe
docker
docker run --rm -it stripe/stripe-cli:latest
Starting webhook local development
The first step to use the stripe CLI is to run the stripe login command.
- Run
stripe loginto login to stripe - Run
stripe listencommand like so, specifying which events you want to register the webhook for, and to which origin and route the webhook should request:
stripe listen -e checkout.session.completed --forward-to http://localhost:3000/webhook
- After successfully listening, copy the outputted webhook secret into your
.envand use it for testing your webhooks.
The -e flag specifies the events you want to listen to, but by default if you omit this option, stripe forwards all events to your webhook.
Triggering events
You can easily trigger events in stripe using the stripe trigger command, which allows you to test stuff like webhook events without the hassle of manually cancelling or updating subscriptions.
stripe trigger <event-name>
To provide data for event types that need data, you would do something like this, putting the data in a file.
stripe trigger customer.created --add-object @./customer_data.json
To see the list of all the different event types, go to webhook events
Logs
To view a realtime stream of logs, you can use the stripe logs tail command:
stripe logs tail
API
The stripe api command allows you to directly call the stripe REST API and perform CRUD operations on stripe resources through the command line.
Customers
stripe api /v1/customers -d email="test@example.com" -d description="Test customer"
stripe api /v1/customers --data-raw '{"email": "json@example.com", "description": "Customer from JSON"}'
Invoices
stripe api /v1/invoices --expand 'data.charge'
Resources
Much like the API, you can access individual resources through stripe, abstracted away vithout having to specify some sort of endpoints.
It works exactly like kubectl, where the resources are different, but the CRUD methods are the same. This is the basic syntax:
stripe <resource> <CRUD_verb>
These are the list of resources:
customersproductschargespricessubscriptionspayment_intentsinvoicescheckout_sessions
These are the CRUD verbs:
list: lists all resources. Here are the additional options you can pass:create: creates a resource.retrieve <id>: returns the resource with the specified IDdelete <id>: deletes the resource with the specified IDupdate <id>: updates he resource with the specified ID