Two server endpoints. That's the entire integration for selling a digital download. You'll write both in Node/Express, run them locally, and watch a real test payment fulfill — exactly as it will in production.
From Lesson 1 you know the flow. In code it's just three things — and you already created the Price in Lesson 1's CLI task:
Created once (Dashboard or CLI). You just need the price_… id.
Buyer clicks Buy → this creates a Checkout Session and redirects them to Stripe's hosted page.
Stripe calls this after payment. You verify the signature and fulfill. This grants the download.
Straight from Stripe's quickstart.[1] Note: secret key, server-side only.
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); // sk_test_…
const app = express();
const DOMAIN = 'http://localhost:4242';
app.post('/create-checkout-session', express.urlencoded({extended:true}), async (req, res) => {
const session = await stripe.checkout.sessions.create({
line_items: [{ price: 'price_123', quantity: 1 }],
mode: 'payment', // one-time
success_url: `${DOMAIN}/success.html`,
cancel_url: `${DOMAIN}/cancel.html`,
});
res.redirect(303, session.url); // send buyer to Stripe's page
});
This is the part that separates a toy from a real integration. Four non-negotiables are baked into the code below — read the annotations.[2]
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_…
// ① RAW body — signature is computed over raw bytes, NOT parsed JSON
app.post('/webhook', express.raw({type:'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// ② VERIFY it's really Stripe (rejects forged/replayed requests)
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
return res.sendStatus(400); // bad signature → reject
}
if (event.type === 'checkout.session.completed' ||
event.type === 'checkout.session.async_payment_succeeded') { // ③ both!
await fulfillCheckout(event.data.object.id);
}
res.sendStatus(200); // ack fast so Stripe stops retrying
});
async function fulfillCheckout(sessionId) {
// re-fetch from the API for fresh, trusted state + the line items
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});
if (alreadyFulfilled(sessionId)) return; // ④ IDEMPOTENT — events can repeat
if (session.payment_status !== 'unpaid') { // paid (or no_payment_required)
grantDownloadAccess(session.customer_details.email, session.line_items);
markFulfilled(sessionId);
}
}
constructEvent + your signing secret.
③ also handle async_payment_succeeded — some methods (e.g. bank debits) settle later, so completed can arrive before the money does.
④ make fulfillment idempotent — Stripe may deliver the same event more than once and out of order.[2]
expand: ['line_items'] to see exactly what was bought — so you fulfill the right thing even if the event is delayed or replayed.
You don't need a deployed HTTPS server. The Stripe CLI tunnels real test events to localhost:[3]
# forwards live test events to your local route AND prints the signing secret to use
stripe listen --forward-to localhost:4242/webhook
> Ready! Your webhook signing secret is whsec_abc123… ← put THIS in STRIPE_WEBHOOK_SECRET
stripe listen prints its own whsec_… for the CLI session. Use that one locally — not the secret from a Dashboard webhook endpoint. Mismatch → every event fails verification with a 400.
/webhook route use express.raw() instead of express.json()?constructEvent re-hashes the raw body and compares to the signature header. Any reserialization (even harmless reformatting) invalidates it.checkout.session.completed event is delivered to your server twice. With the code above, what happens?alreadyFulfilled() guards that.checkout.session.completed. A buyer pays with a delayed bank debit. What's the risk?completed can fire while payment is still processing. The payment_status !== 'unpaid' check plus the async event keep fulfillment honest.async_payment_succeeded and gate on payment_status.STRIPE_WEBHOOK_SECRET.stripe listen session prints the secret you must use locally.npm init -y && npm i express stripe. Drop the two endpoints above into server.js; for now make fulfillCheckout just console.log('✅ fulfilled', sessionId) with an in-memory Set for the idempotency check.export STRIPE_SECRET_KEY=sk_test_… (from the Dashboard) and run the server.stripe listen --forward-to localhost:4242/webhook. Copy the printed whsec_… into STRIPE_WEBHOOK_SECRET and restart the server.4242 4242 4242 4242, any future expiry/CVC. (Or POST to your own /create-checkout-session.)✅ fulfilled. Pay a second time and confirm a duplicate event would be skipped. You just built a complete digital-product checkout.grantDownloadAccess to a real signed-URL/S3 download? Persist idempotency in a DB or queue the work? Add a Customer so you can email a receipt? Say the word and we'll go deeper before Lesson 3.