Build a Digital Product Store
Integrate Stripe Checkout into a Next.js app with webhook payment confirmation.
Introduction
30 Second Summary
Every time you buy something online, a payment gateway handles the complex exchange of money between your bank and the seller. Building that yourself from scratch would mean handling credit card numbers, fraud detection, and security compliance.
In this project, you will build a digital product store using Next.js and Stripe that accepts test payments through a hosted checkout page and confirms orders reliably using webhooks.
What You'll Build
You'll click "Buy Now" on your own storefront, watch a professional Stripe Checkout page appear, complete a test payment with a fake card number, and see your server log the confirmed purchase in real-time.
By the end of this project, you'll have:
- A working storefront where clicking "Buy Now" redirects to Stripe's hosted checkout page in test mode, so no real money changes hands.
- A webhook endpoint that captures every successful payment, even if the customer's browser crashes before the redirect completes.
- Hands-on understanding of the Checkout Session + webhook architecture that production apps use to process real payments.
- Secret Mission: Expand your store into a multi-product catalog with dynamic pricing.
Are there any prerequisites?
You should be comfortable with JavaScript and Next.js basics (App Router and route handlers). You'll also need VS Code and Node.js installed on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. You're about to build a digital product store that accepts real payments (in test mode), so it's worth thinking about what you're selling and why getting payment confirmation right is critical.
Set Up Stripe and Your Next.js Project
To accept payments in your store, you need a connection to a payment gateway. Stripe provides this connection, handling card details securely so your server never has to.
In this step, you will create a Stripe account, install the Stripe CLI for local testing, and scaffold a Next.js project with the Stripe SDK installed.
In this step, get ready to:
- Create a Stripe account and copy your test API keys.
- Install the Stripe CLI and authenticate it.
- Scaffold a Next.js project with the Stripe packages installed.
Create your Stripe account and grab your API keys
Stripe has a Test Mode that mirrors production without processing real money. You will work exclusively in Test Mode for this project.
- Open your browser and navigate to https://dashboard.stripe.com/register.
- Create your free Stripe account by filling out the registration form.
- Once you are in the Dashboard, look at the top-right corner and confirm the Test mode toggle is switched on.
- Click Developers in the left sidebar.
- Click API keys.
- Copy your Publishable key (starts with pk_test_) and paste it somewhere safe like a text file.
- Click Reveal test key to show your Secret key (starts with sk_test_), then copy and save it alongside your publishable key.
What are these two keys?
The publishable key is safe to include in client-side code. It identifies your account but cannot make charges.
The secret key can create charges and must never be exposed in the browser. You will keep it in an environment variable on your server.
Install and authenticate the Stripe CLI
The Stripe CLI lets you forward webhook events to your local machine during development. You will use it in a later step to test payment confirmations.
macOS
- Install the Stripe CLI using Homebrew by running this command:
brew install stripe/stripe-cli/stripe
Don't have Homebrew?
Install Homebrew first by visiting https://brew.sh and running the install command shown on the page. Then retry the command above.
Help me install Homebrew on my Mac.
Windows
- Install the Stripe CLI using Scoop by running these commands:
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe
Don't have Scoop?
Install Scoop first by visiting https://scoop.sh and following the install instructions. Then retry the commands above.
Help me install Scoop on Windows.
Linux
- Download the latest Linux release from https://github.com/stripe/stripe-cli/releases/latest.
- Look for the file ending in _linux_x86_64.tar.gz and download it.
- Open your terminal and navigate to the folder where it downloaded (usually ~/Downloads).
- Extract the archive and move the binary to your PATH by running these commands:
tar -xvf [[STRIPE_TARBALL="your downloaded filename, e.g. stripe_1.25.0_linux_x86_64.tar.gz"]]
sudo mv stripe /usr/local/bin/
Permission denied?
The sudo command requires your system password. Type it when prompted. The characters will not appear on screen as you type.
Help me move the Stripe binary to my PATH on Linux.
- Authenticate the CLI with your Stripe account by running:
stripe login
What happens when you run this?
The CLI prints a URL and opens your browser to a Stripe authorization page. Press Enter in the terminal when prompted, then click Allow access in your browser to complete authentication.
- Verify the Stripe CLI is installed and authenticated by running:
stripe --version
✔️ I see a version number
The Stripe CLI is installed and ready. You will use it in Step 4 to forward webhook events to your local server.
ⓧ Command not found
The CLI binary is not on your system PATH. Double check that the install command completed without errors.
- On macOS, try running brew list stripe to confirm Homebrew installed it.
- On Linux, confirm the binary is at /usr/local/bin/stripe by running ls /usr/local/bin/stripe.
- On Windows, close and reopen your terminal so Scoop's PATH changes take effect.
Help me fix the Stripe CLI not being found.
Scaffold your Next.js project and install Stripe packages
Your store needs a Next.js project with the Stripe SDK installed. You will scaffold a new project with the App Router and skip extras like Tailwind and ESLint to keep things focused.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running this command:
npx create-next-app@latest stripe-store --app --no-tailwind --no-src-dir --no-eslint --no-turbopack --no-import-alias
What do these flags do?
Each flag skips an interactive prompt and locks in a choice. --app enables the App Router. The --no-tailwind, --no-src-dir, --no-eslint, --no-turbopack, and --no-import-alias flags turn off features you do not need for this project.
You should see the CLI creating files and printing a success message when it finishes.
- Move into the new project folder and install the Stripe packages by running these commands:
cd stripe-store
npm install stripe @stripe/stripe-js
What are these two packages?
The stripe package is the server-side SDK. Your route handlers use it to create Checkout Sessions and verify webhooks.
The @stripe/stripe-js package loads Stripe's client-side library. You will not use it directly in this project's redirect flow, but it is standard practice to include it.
You should see npm download the packages and print an "added X packages" summary.
- Create a .env.local file in the root of stripe-store to hold your API keys. In VS Code, right-click in the Explorer sidebar and select New File.
- Name the file .env.local and press Enter.
- Paste the following into the file, replacing the placeholder values with the real keys you copied from the Stripe Dashboard:
STRIPE_SECRET_KEY=sk_test_your-secret-key-here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key-here
Why these specific variable names?
The NEXT_PUBLIC_ prefix tells Next.js to expose a variable to the browser. Your publishable key needs this because it identifies your Stripe account in client-side code.
The STRIPE_SECRET_KEY has no prefix, so Next.js keeps it server-only. Your secret key should never reach the browser.
- Save .env.local.
✔️ Awesome, I've got everything!
Great. Double check you have replaced both placeholder values with your actual Stripe test keys.
ⓧ I'd like to double check the full code
Your .env.local should look exactly like this (with your real keys in place of the placeholders):
STRIPE_SECRET_KEY=sk_test_your-secret-key-here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key-here
Before you run the dev server, what do you expect to see at http://localhost:3000?
- Start the development server by running:
npm run dev
Open http://localhost:3000 in your browser. You should see the default Next.js starter page with the Vercel logo and "Get started by editing" text.
Not seeing the page?
- Check your terminal for error messages. A common cause is another process already using port 3000.
- Make sure you ran npm run dev from inside the stripe-store folder, not its parent.
- If you see an error about missing modules, try running npm install again.
Help me get my Next.js dev server running.
Your environment is ready. Next up, you will build a product page and wire it to Stripe Checkout so clicking "Buy Now" opens a real payment form.
Build the Product Page and Trigger Checkout
Your development environment is ready with the Stripe SDK installed and your API keys configured. Now it's time to build something a customer can actually interact with.
Before you can accept any payments, you need two things: a product page that shows what's for sale, and a server-side route that creates a Checkout Session. By the end of this step, clicking a button on your page opens Stripe's professionally-built payment form.
In this step, get ready to:
- Build a product page with a "Buy Now" button.
- Create a server-side route that initiates Stripe Checkout.
- Test the full redirect flow from your page to Stripe's hosted checkout.
Create your product page
Your store needs a product to sell. You'll replace the default Next.js starter page with a simple product card that includes a name, description, price, and a "Buy Now" button.
- Open app/page.js in VS Code.
- Select all the existing content and replace it with the following code:
export default function Home() {
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>Digital Product Store</h1>
{/* Product card */}
<div style={{ border: '1px solid #e0e0e0', borderRadius: '8px', padding: '1.5rem', marginTop: '1rem' }}>
<h2>Next.js Mastery Guide</h2>
<p>The complete guide to building production apps with Next.js.</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>$29.00</p>
{/* Form POSTs to the checkout API route */}
<form action="/api/checkout" method="POST">
<button
type="submit"
style={{
backgroundColor: '#635bff',
color: 'white',
border: 'none',
padding: '12px 24px',
borderRadius: '4px',
fontSize: '1rem',
cursor: 'pointer',
}}
>
Buy Now
</button>
</form>
</div>
</main>
);
}
What does this code do?
- The <form> element uses method="POST" and action="/api/checkout" to send a POST request to your server when the button is clicked.
- The button is styled in Stripe's signature purple (#635bff) to hint that a payment flow is coming.
- No JavaScript is needed on the client side for this form. The browser handles the POST natively.
- Save app/page.js.
- Return to your browser at http://localhost:3000 and refresh the page. You should see a product card with the title "Next.js Mastery Guide", a price of $29.00, and a purple "Buy Now" button.
Don't see the product card?
- Make sure your dev server is still running. If it stopped, run npm run dev again in your terminal.
- Check that you replaced the entire contents of app/page.js and saved the file.
Still stuck? Why isn't my product page showing up after I replaced the code in app/page.js?
✔️ Awesome, I've got everything!
Great. Make sure you've saved app/page.js before moving on.
ⓧ I'd like to double check the full code
export default function Home() {
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>Digital Product Store</h1>
<div style={{ border: '1px solid #e0e0e0', borderRadius: '8px', padding: '1.5rem', marginTop: '1rem' }}>
<h2>Next.js Mastery Guide</h2>
<p>The complete guide to building production apps with Next.js.</p>
<p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>$29.00</p>
<form action="/api/checkout" method="POST">
<button
type="submit"
style={{
backgroundColor: '#635bff',
color: 'white',
border: 'none',
padding: '12px 24px',
borderRadius: '4px',
fontSize: '1rem',
cursor: 'pointer',
}}
>
Buy Now
</button>
</form>
</div>
</main>
);
}
Create the checkout API route
When the form submits, the browser sends a POST request to /api/checkout. You need a server-side route handler at that path to create a Stripe Checkout Session and redirect the user to Stripe's hosted payment page.
- In the app folder of your stripe-store project, create a new folder called api.
- Inside api, create another folder called checkout.
- Inside checkout, create a new file called route.js.
- Paste the following code into app/api/checkout/route.js:
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const origin = request.headers.get('origin') || 'http://localhost:3000';
const session = await stripe.checkout.sessions.create({
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Next.js Mastery Guide',
},
unit_amount: 2900, // Price in cents ($29.00)
},
quantity: 1,
},
],
mode: 'payment', // One-time payment
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/`,
});
return NextResponse.redirect(session.url, 303); // Redirect to Stripe checkout
}
What does this code do?
- new Stripe(process.env.STRIPE_SECRET_KEY) initialises the Stripe SDK with your secret key from .env.local.
- stripe.checkout.sessions.create() creates a Checkout Session. This tells Stripe what product you're selling, the price (2900 = $29.00 in cents), and where to send the customer after payment.
- {CHECKOUT_SESSION_ID} is a template variable that Stripe automatically replaces with the real session ID in the redirect URL.
- NextResponse.redirect(session.url, 303) sends the customer's browser to Stripe's hosted payment page. The 303 status code tells the browser to follow the redirect with a GET request.
Your server never sees or handles credit card data. Stripe's hosted page takes care of all the sensitive payment details.
What is a Route Handler?
In Next.js App Router, you create API endpoints by placing a route.js file inside nested folders under app. The folder path becomes the URL. So app/api/checkout/route.js handles requests to /api/checkout.
You export a function named after the HTTP method you want to handle. Exporting POST means this route only responds to POST requests.
- Save app/api/checkout/route.js.
Seeing a file structure error?
- Make sure your folder structure is exactly app/api/checkout/route.js. Each level needs its own folder.
- Confirm you have STRIPE_SECRET_KEY in your .env.local file. If the value is missing, the Stripe SDK will fail to initialise.
Need help? My checkout route file isn't being found at /api/checkout. How do I fix the folder structure?
✔️ Awesome, I've got everything!
Great. Make sure you've saved app/api/checkout/route.js before testing.
ⓧ I'd like to double check the full code
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const origin = request.headers.get('origin') || 'http://localhost:3000';
const session = await stripe.checkout.sessions.create({
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Next.js Mastery Guide',
},
unit_amount: 2900,
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/`,
});
return NextResponse.redirect(session.url, 303);
}
Test the checkout redirect
Both pieces are in place: a product page with a form, and a server route that creates a Checkout Session. Time to see them work together.
- Return to your browser at http://localhost:3000.
Before you click anything, think about what should happen. When you click "Buy Now", the form sends a POST to your server. Your server creates a Checkout Session with Stripe and redirects you. Where do you think you'll end up?
- Click the Buy Now button.
You land on Stripe's hosted checkout page. It shows "Next.js Mastery Guide" at $29.00 with a full payment form including card fields, email, and billing details.
Why does Stripe handle the payment form?
Stripe's hosted checkout page handles all sensitive card data so your server never touches it. This gives you PCI compliance without any extra work. The same architecture is used by production apps processing millions in payments.
Your form just triggers the redirect. Stripe renders the card fields, validates input, handles 3D Secure, and processes the charge.
Not redirecting to Stripe?
- Check your terminal for error messages. A common issue is an invalid or missing STRIPE_SECRET_KEY in .env.local.
- Make sure your key starts with sk_test_. If it starts with pk_test_, you've pasted the publishable key in the wrong place.
- If you see a 404, confirm the folder path is app/api/checkout/route.js (not app/api/checkout.js).
Still stuck? When I click Buy Now I get an error instead of the Stripe Checkout page. Help me debug.
Your store now has a working checkout flow. Next up, you'll complete a test payment and discover why relying on the success URL alone is a problem.
Complete a Test Payment and Discover the Success URL Problem
In the last step, you wired up a Buy Now button that creates a Checkout Session and sends customers to Stripe's hosted payment page.
Now you need somewhere for customers to land after they pay. You also need to see the full payment loop in action: click, pay, confirm. Once that happy path works, you will stress-test it to find out whether it is robust enough for a real product.
In this step, get ready to:
- Build a success page that displays the payment session ID.
- Complete a full test payment and land on your confirmation page.
- Discover a critical reliability gap in the success-URL approach.
Create the success page
After a customer pays, Stripe redirects their browser to the success_url you configured in the previous step. That URL points to /success and includes a session_id query parameter. You need a page there to catch the redirect and show a confirmation.
- Create a new file at app/success/page.js inside your stripe-store project folder.
- Add the following code to build your success page:
export default async function Success({ searchParams }) {
// In Next.js 16, searchParams is a promise so we await it
const params = await searchParams;
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem', textAlign: 'center' }}>
<h1>Payment Successful!</h1>
<p>Thank you for your purchase. Your order has been confirmed.</p>
<p style={{ color: '#666', fontSize: '0.875rem' }}>
Session ID: {params.session_id}
</p>
<a href="/" style={{ color: '#635bff' }}>Back to store</a>
</main>
);
}
What does this code do?
This is an async server component. In Next.js 16, the searchParams prop is a Promise, so the component must be async and await the params before reading them.
- params.session_id reads the session ID that Stripe appends to the URL after a successful payment.
- The page displays a simple confirmation message and a link back to the store.
- Save app/success/page.js.
- Open http://localhost:3000/success?session_id=test123 in your browser to confirm the page renders with the test session ID.
You should see "Payment Successful!" with "Session ID: test123" displayed below it.
Seeing a 404 or blank page?
- Make sure the file is at app/success/page.js (not app/success.js). Next.js App Router needs a folder with a page.js inside it.
- Confirm your dev server is still running. If it crashed, run npm run dev again in your terminal.
- help me debug my success page not rendering
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
export default async function Success({ searchParams }) {
const params = await searchParams;
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem', textAlign: 'center' }}>
<h1>Payment Successful!</h1>
<p>Thank you for your purchase. Your order has been confirmed.</p>
<p style={{ color: '#666', fontSize: '0.875rem' }}>
Session ID: {params.session_id}
</p>
<a href="/" style={{ color: '#635bff' }}>Back to store</a>
</main>
);
}
Complete a full test payment
Your success page is ready. Time to run through the entire checkout flow from start to finish.
- Go to http://localhost:3000 in your browser.
- Click the Buy Now button. You should land on Stripe's hosted checkout page showing "Next.js Mastery Guide" at $29.00.
- In the Email field, enter any email address (e.g. test@example.com).
- In the Card number field, enter 4242 4242 4242 4242.
- Enter any future expiry date (e.g. 12/30).
- Enter any 3-digit CVC (e.g. 123).
- Enter any billing ZIP code (e.g. 10001).
- Click Pay.
Before you look at the result, what do you expect to see next? Think about what success_url is set to in your checkout route.
You should land on your success page showing "Payment Successful!" along with a real Stripe session ID (a long string starting with cs_test_).
What is 4242 4242 4242 4242?
This is Stripe's universal test card number. It always succeeds in test mode. No real money is charged. Stripe provides different test card numbers to simulate declined cards, 3D Secure prompts, and other scenarios.
Stuck on the Stripe page or seeing an error?
- Make sure you are in Test Mode in your Stripe Dashboard (the toggle in the top-right). If your keys start with pk_live_ you are using live keys by mistake.
- Confirm your .env.local has both STRIPE_SECRET_KEY and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY set correctly and that you restarted the dev server after editing env vars.
- help me debug my Stripe Checkout redirect not working
Discover why production apps never trust the success URL
Your happy path works. A customer pays, Stripe redirects them to your success page, and your app knows about the purchase because it can read the session ID from the URL. Seems solid, right?
- Go back to http://localhost:3000 and click Buy Now again to start a new checkout.
- On the Stripe Checkout page, fill in the same test card details as before (4242 4242 4242 4242, any future expiry, any CVC, any ZIP).
- Click Pay.
- Immediately close the browser tab before the redirect completes. Do not wait for your success page to load.
What do you think happened to that payment? Did Stripe process it, or did closing the tab cancel it?
- Open your Stripe Dashboard and navigate to https://dashboard.stripe.com/test/payments.
- Look at the most recent payment. Notice the status column shows Succeeded.
The payment went through on Stripe's side. The customer was charged. But your app never received the redirect, so it has no record that this payment happened.
This is the success URL problem
If the customer closes their browser, loses their internet connection, or the redirect simply fails, your success page never loads. Your server never learns about the payment.
Relying on the success URL for order fulfilment means you will silently lose payments. This is why production apps never use the redirect alone to confirm orders.
Why does this matter?
Imagine a customer buys a digital download, the payment succeeds, but they close the tab a moment too early. They were charged, but your app never delivered the product. This is not a theoretical edge case. It happens often enough that Stripe's own documentation warns against it.
The fix is webhooks. Instead of waiting for the customer's browser to carry the good news back to your server, Stripe sends an HTTP POST directly to your server whenever a payment event occurs. You will set this up in the next step.
You have experienced firsthand why the success URL is not enough. In the next step, you will add a webhook endpoint that receives payment confirmations directly from Stripe, so your server always knows when money arrives.
Add Webhooks for Reliable Payment Confirmation
In the previous step, you saw the critical gap in your payment flow: a customer can pay successfully, but if their browser closes before the redirect, your app never finds out.
Webhooks solve this. Instead of relying on the customer's browser to deliver good news, Stripe sends an HTTP POST directly to your server whenever a payment event occurs. Your server gets the confirmation regardless of what happens in the browser.
In this step, get ready to:
- Create a webhook endpoint that receives and verifies Stripe events.
- Connect the Stripe CLI to forward events to your local server.
- Confirm that a test payment triggers a logged confirmation in your server console.
Create the webhook endpoint
Your webhook endpoint needs to do three things: read the raw request body, verify that the request actually came from Stripe (using a cryptographic signature), and then process the event.
- Create a new file at app/api/webhooks/route.js in your stripe-store project.
- Add the following code to set up the POST handler with signature verification:
import Stripe from 'stripe';
// Initialize Stripe with the secret key
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
// Read the raw body as text (not JSON) for signature verification
const body = await request.text();
const signature = request.headers.get('stripe-signature');
let event;
// Verify the event came from Stripe using HMAC signature
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
What does this code do?
This is the security layer of your webhook. Every webhook Stripe sends includes a cryptographic signature in the stripe-signature header.
- request.text() reads the raw body as a string. You cannot use request.json() because the signature verification needs the exact raw bytes.
- stripe.webhooks.constructEvent() compares the signature against a hash of the body and your webhook secret. If someone tries to fake a payment event, the signature won't match and the request is rejected with a 400 status.
- Below the catch block's closing brace, add the event handling and response logic:
// Handle the checkout completion event
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
console.log(`Payment confirmed for session: ${session.id}`);
console.log(`Customer email: ${session.customer_details.email}`);
console.log(`Amount paid: $${session.amount_total / 100}`);
}
// Acknowledge receipt so Stripe doesn't retry
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
What does this code do?
Once the signature is verified, you know the event is legitimate. This section processes it.
- event.type === 'checkout.session.completed' filters for the specific event that fires when a customer finishes paying.
- event.data.object contains the full Checkout Session with customer details and the amount paid.
- Returning a 200 response tells Stripe you received the event successfully. Without this, Stripe would retry delivery for up to 72 hours.
- Save app/api/webhooks/route.js.
Seeing a syntax error?
Make sure the closing } of the POST function appears after the return new Response(...) statement. The if block and the final return must both be inside the POST function body.
help me fix a syntax error in my webhook route handler
✔️ Awesome, I've got everything!
Great. Double check you have saved app/api/webhooks/route.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/api/webhooks/route.js file. Make sure yours matches exactly:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
console.log(`Payment confirmed for session: ${session.id}`);
console.log(`Customer email: ${session.customer_details.email}`);
console.log(`Amount paid: $${session.amount_total / 100}`);
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
Start the Stripe CLI webhook listener
Your webhook endpoint exists, but Stripe has no way to reach localhost from the internet. The Stripe CLI solves this by opening a direct connection to Stripe and forwarding events to your local server.
- Open a new terminal window or tab. Keep your existing terminal with npm run dev running.
- Start the webhook listener by running this command:
stripe listen --forward-to localhost:3000/api/webhooks
The CLI prints a message like this when it connects:
> Ready! Your webhook signing secret is whsec_... (^C to quit)
- Copy the webhook signing secret that starts with whsec_ from the CLI output.
Why a separate signing secret?
The Stripe CLI generates its own signing secret for local forwarding. This is different from a production webhook secret you would get from the Stripe Dashboard. When testing locally, always use the secret the CLI gives you.
- Switch back to VS Code and open .env.local.
- Find this line:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key-here
- Add a new line below it with your webhook secret:
STRIPE_WEBHOOK_SECRET=whsec_your-webhook-secret-here
- Replace whsec_your-webhook-secret-here with the actual secret you copied from the Stripe CLI.
- Save .env.local.
Why restart the dev server?
Next.js only reads .env.local when the server starts. Adding a new environment variable requires a restart for the app to see it.
- Switch to the terminal running your Next.js dev server.
- Press Ctrl+C to stop it.
- Restart it by running:
npm run dev
Your app is now running with the webhook secret loaded, and the Stripe CLI is forwarding events to it.
Stripe CLI showing connection errors?
Make sure your Next.js dev server is running on port 3000 before starting stripe listen. If port 3000 is taken, stop other processes using it and restart with npm run dev.
help me troubleshoot Stripe CLI connection issues with my local webhook
Test the full payment flow with webhooks
Everything is wired up. Time to prove that your webhook captures payment confirmations reliably.
Before you run through a test payment, what do you expect to see in your Next.js server console when the payment completes?
- Open your browser to http://localhost:3000.
- Click the Buy Now button.
- On the Stripe Checkout page, enter test card number 4242 4242 4242 4242.
- Enter any future expiry date, any 3-digit CVC, and any billing ZIP.
- Click Pay.
After the payment completes, check both terminals.
In the Stripe CLI terminal, you should see the event being forwarded with a line like checkout.session.completed and a 200 response status.
In your Next.js server console, you should see three log lines: the session ID, the customer email you entered, and the amount paid ($29.00).
Why this matters
Even if the browser redirect had failed, your webhook endpoint still received the payment confirmation directly from Stripe. This is how production payment systems work. The success URL is a courtesy for the customer. The webhook is the source of truth for your business logic.
Not seeing logs in your server console?
- Check that the Stripe CLI terminal shows a 200 response (not 400). A 400 means signature verification failed. Double check your STRIPE_WEBHOOK_SECRET in .env.local matches the secret the CLI printed.
- If the CLI shows events but your server shows nothing, confirm you restarted the dev server after adding the webhook secret.
- If the CLI shows no events at all, make sure it is still running (Ready! should appear at the top of the output).
help me debug why my webhook isn't logging payment confirmations
Secret mission
Expand Your Store Into a Multi-Product Catalog
Your store sells one product right now. Real stores have catalogs. Can you expand your checkout to dynamically handle multiple products with different prices, while keeping price determination secure on the server side?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Stripe in test mode, so there are no ongoing costs.
Resources you used:
- Stripe account (free, test mode only).
- Local Next.js project (stripe-store folder on your Desktop).
- Stripe CLI installed and authenticated session.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend this project soon.
- Stripe test mode has no charges or expiration. Your account stays free indefinitely.
- Your local project files remain on your Desktop.
- The Stripe CLI stays installed and authenticated on your machine. You can re-run stripe login any time your session expires.
Pause - I'll come back to this later
Free up system resources by stopping the running processes. Your files and account stay intact.
- In the terminal running stripe listen, press Ctrl+C to stop the webhook listener.
- In the terminal running your Next.js dev server, press Ctrl+C to stop it.
- Your Stripe account and local project files stay as-is. No costs accrue in test mode.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Stop both running processes (stripe listen and the dev server) by pressing Ctrl+C in each terminal.
- Log the Stripe CLI out of your account by running:
stripe logout
- Delete the local project folder by running:
cd ~/Desktop
rm -rf stripe-store
- Remove the Stripe CLI binary and its config file by running:
sudo rm /usr/local/bin/stripe
rm -rf ~/.config/stripe
- To close your Stripe account entirely, go to https://dashboard.stripe.com/settings/account and follow the account closure instructions at the bottom of that page. Stripe recommends leaving test accounts dormant instead, in case you need to access payment data later.
That's a Wrap!
That's a Wrap!
Nice work! You just built a digital product store that accepts payments through Stripe, using the same architecture that production apps handling real money rely on.
You've learned how to:
- Built a Next.js storefront with Stripe Checkout Sessions created server-side, keeping card data off your server for PCI compliance.
- Discovered why success URL redirects are unreliable and implemented webhook-based payment confirmation that captures every payment regardless of what the customer's browser does.
- Learned the end-to-end payment architecture (Checkout Sessions + webhooks) used by production apps processing real payments, and can explain to an interviewer why you never trust a success URL for order fulfilment.
- Secret Mission: Expanded your store into a multi-product catalog with dynamic pricing and server-side product lookup.
Ready to quiz yourself?