Lesson 2 · One-time downloads

Build the one-time
download flow

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.

Mission link: this is the first half of your goal — sell a digital file, take money by hand, and reliably grant access. We build it for real here; subscriptions reuse the same skeleton in Lesson 3.

The whole architecture

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:

done A Product + one-time Price

Created once (Dashboard or CLI). You just need the price_… id.

endpoint 1 POST /create-checkout-session

Buyer clicks Buy → this creates a Checkout Session and redirects them to Stripe's hosted page.

endpoint 2 POST /webhook

Stripe calls this after payment. You verify the signature and fulfill. This grants the download.

Endpoint 1 — create the session

Straight from Stripe's quickstart.[1] Note: secret key, server-side only.

server.js
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
});

Endpoint 2 — the webhook (where the real work is)

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]

server.js (continued)
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);
  }
}
The four things you must get right: ① use the raw body on the webhook route (parse JSON everywhere else, but not here — it breaks the signature). ② verify with 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]
Why re-fetch the session instead of trusting the event payload? The payload is a point-in-time snapshot. Re-retrieving gives you fresh state and lets you expand: ['line_items'] to see exactly what was bought — so you fulfill the right thing even if the event is delayed or replayed.

Run it locally — the dev loop

You don't need a deployed HTTPS server. The Stripe CLI tunnels real test events to localhost:[3]

terminal — leave running
# 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
Gotcha that bites everyone: 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.

Check yourself

1. Why does the /webhook route use express.raw() instead of express.json()?
Right. constructEvent re-hashes the raw body and compares to the signature header. Any reserialization (even harmless reformatting) invalidates it.
It's about integrity, not speed or format. The signature is over the raw bytes — parse them and the hash won't match, so verification fails.
2. The same checkout.session.completed event is delivered to your server twice. With the code above, what happens?
Exactly why ④ matters. The charge already happened on Stripe's side; your job is to not double-fulfill. alreadyFulfilled() guards that.
Re-delivery is normal and the signature is still valid. The protection is your idempotency check — fulfill once, record it, skip repeats.
3. You handle only checkout.session.completed. A buyer pays with a delayed bank debit. What's the risk?
Correct. completed can fire while payment is still processing. The payment_status !== 'unpaid' check plus the async event keep fulfillment honest.
Not all methods are instant. Card is, but bank debits settle later — so you watch async_payment_succeeded and gate on payment_status.
4. Running locally, every event returns 400 "signature verification failed." Most likely cause?
The classic. The CLI session has its own signing secret — copy the one it prints into STRIPE_WEBHOOK_SECRET.
A 400 here is specifically a signing-secret mismatch. The stripe listen session prints the secret you must use locally.

⚡ Your tangible win (≈20 min) — a real test purchase that fulfills

  1. 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.
  2. Export your keys: export STRIPE_SECRET_KEY=sk_test_… (from the Dashboard) and run the server.
  3. In another terminal: stripe listen --forward-to localhost:4242/webhook. Copy the printed whsec_… into STRIPE_WEBHOOK_SECRET and restart the server.
  4. Trigger a real test checkout. Easiest path: create a test Payment Link for your Price and pay with card 4242 4242 4242 4242, any future expiry/CVC. (Or POST to your own /create-checkout-session.)
  5. Watch your server log ✅ fulfilled. Pay a second time and confirm a duplicate event would be skipped. You just built a complete digital-product checkout.
Ask me anything. Want to swap hosted Checkout for an embedded form on your own page? Wire 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.

Citations

  1. Stripe — Accept a payment with Checkout (session creation, 303 redirect)
  2. Stripe — Fulfill orders with the Checkout API (raw body, constructEvent, retrieve+expand, payment_status, idempotency, async_payment_succeeded)
  3. Stripe CLI — stripe listen / forward-to and the per-session signing secret