Stripe One-Time Checkout
Selling one thing once is the easiest money problem you will ever have, which is exactly why people get it wrong: it looks too simple to need a spec. You make a Checkout Session, the customer pays, they land on your thank-you page, and you hand over the goods right there in the page code. Then someone's connection drops between paying and redirecting, and you have a paid customer staring at nothing, emailing you at 11pm. Or someone bookmarks the success URL and gets the goods for free every time they visit it.
The thank-you page is decoration. Stripe tells you about the payment the reliable way, server to server, through a webhook, and that is where fulfillment happens: mark the order paid, send the download link, grant the access. The page the customer lands on just reads the order and displays its state. If the redirect never happens, they still get what they paid for. That is the entire trick, and it's the difference between a checkout that works in a demo and one that works on a bad hotel wifi.
This spec is deliberately smaller than the subscription one. No customer portal, no recurring state machine, no plan table. One product, one payment, one webhook event that matters. Fill in the placeholders and hand it to your agent.
Prerequisites
- A Stripe account with a product and one-time price created in the dashboard (test mode is fine).
- The Stripe CLI installed for local webhook forwarding (`stripe listen`).
# Spec: one-time purchase with Stripe Checkout
Sell [the product: an ebook / a course / a license / a physical thing] as a
single payment. Stripe hosts the payment page. Fulfillment happens in the
webhook handler, never in the success page.
## Stack
- Framework: [e.g., Next.js 14 App Router / Express / Flask]
- Database: [e.g., Supabase Postgres / SQLite]
- Customers may be [logged-in users / anonymous email buyers; pick one]
## Environment variables
```bash
STRIPE_SECRET_KEY=sk_test_... # server only, never exposed to the client
STRIPE_WEBHOOK_SECRET=whsec_... # `stripe listen` locally, dashboard in prod
STRIPE_PRICE_PRODUCT=price_... # created in the dashboard, not in code
APP_URL=http://localhost:3000
```
## Data model
```sql
create table orders (
id uuid primary key default gen_random_uuid(),
email text not null,
stripe_session_id text unique not null, -- unique = a session fulfills once
status text not null default 'pending', -- 'pending' | 'paid'
fulfilled_at timestamptz,
created_at timestamptz not null default now()
);
```
Only the webhook handler moves an order to 'paid'. The success page and every
other route are read-only against this table.
## Flow 1: start checkout (POST /api/checkout)
1. Create an order row with status 'pending'.
2. Create a Checkout Session: `mode: "payment"`, the price from env,
quantity 1, success URL `[APP_URL]/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel URL `[APP_URL]/[product page]`. Store the session id on the order.
3. Redirect the customer to the session URL.
## Flow 2: the webhook (POST /api/webhooks/stripe)
1. Verify the signature with STRIPE_WEBHOOK_SECRET against the RAW request
body (disable framework body parsing on this route). 400 on failure.
2. Handle `checkout.session.completed` where `payment_status` is `"paid"`.
Return 200 for every other event type without doing anything.
3. Fulfill idempotently: update the order by `stripe_session_id` only where
status is still 'pending'. Stripe retries deliveries; the second delivery
must be a no-op, not a second download email.
```typescript
// the only write that grants the product
const updated = await db.orders.update({
where: { stripe_session_id: session.id, status: "pending" },
data: { status: "paid", fulfilled_at: new Date() },
});
if (updated) await fulfill(order);
// fulfill(): [send the download link / grant access / queue the shipment]
```
## Flow 3: the thank-you page (GET /thanks)
1. Look up the order by the `session_id` query param.
2. If status is 'paid', show the confirmation and [the download link / next
steps]. If still 'pending', show "payment received, finishing up" and
refresh; the webhook usually lands within seconds.
3. This page never fulfills anything. Visiting it twice, or with a guessed
session id, grants nothing the webhook didn't already grant.
## Verification (test mode, before calling this done)
1. `stripe listen --forward-to localhost:3000/api/webhooks/stripe` running.
2. Buy with test card 4242 4242 4242 4242; confirm the order flips to 'paid'
in the webhook log before the thank-you page renders it.
3. Kill the redirect (close the tab after paying); confirm the order still
gets fulfilled.
4. Replay the event with `stripe events resend`; confirm exactly one
fulfillment happened.Adaptation notes:
- Multiple products: pass the price ID into the checkout route from an allowlist in your code. Never accept an amount from the client; a user who can name their own price will.
- Digital goods need an expiring download link, not a public file URL. If the URL works in an incognito window a week later, you shipped a free product with extra steps.
- Physical goods: enable address collection on the Checkout Session and copy the shipping address onto the order in the webhook. Fulfillment becomes "queue it for shipping" instead of "send the link"; nothing else changes.
- For quick low-stakes sales, a Stripe Payment Link gets you live with zero code, but fulfillment is manual. The moment you want the product delivered automatically, you are back to this spec.
- The mistake: fulfilling on the thank-you page because it works when you test it. It works because your wifi didn't drop and you didn't share the URL. Real customers will do both within the first week.