Sell products or accept donations with Stripe-hosted checkout pages.
"Buy" buttons on your site that hand off to Stripe's hosted checkout — products, one-off payments, or donations. Products are content the owner edits in Studio (name, price, image); orders land in an app table you can inspect. Card data never touches your server.
Requires a Stripe account — a paid third-party service (per-transaction fees). Clearly out of scope for projects that must stay account-free.
A product content type carries the catalog. A custom route creates a Stripe Checkout Session server-side using your secret key from an env var; the buyer pays on Stripe's page and returns to yours. A webhook route verifies Stripe's signature and records completed orders in an x_orders table.
You are adding Stripe-hosted checkout to a Prelo project. Env vars: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, plus `SITE_URL` for return URLs. Use Stripe's REST API directly with `fetch` — do not add the `stripe` npm package unless the user asks (keeps the dependency budget).
1. **Content type.** In `cms.config.js`:
```js
product: {
label: "Products",
fields: {
price: { type: "number", required: true }, // in cents
currency: { type: "select", options: ["usd", "eur", "gbp"], required: true },
image: { type: "image" },
description: { type: "text" },
mode: { type: "select", options: ["payment", "donation"] },
},
studio: { columns: ["price", "currency", "mode"] },
},
```
Published = buyable. Prices live in the CMS (created inline on the Checkout Session), so the owner edits them in Studio without touching the Stripe dashboard.
2. **Orders table.** `hooks.boot`:
```js
db.exec(`CREATE TABLE IF NOT EXISTS x_orders (
id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, product_slug TEXT,
amount INTEGER, currency TEXT, email TEXT, status TEXT, created INTEGER)`);
```
3. **Checkout route.** `"POST /checkout"` in `routes:`:
- Body: `{ slug }`. Look up the **published** product; 404 otherwise.
- `POST https://api.stripe.com/v1/checkout/sessions` (form-encoded, `Authorization: Bearer STRIPE_SECRET_KEY`) with an inline `line_items[0][price_data]` from the product's price/currency/title, `mode=payment`, `success_url={SITE_URL}/thanks?session={CHECKOUT_SESSION_ID}`, `cancel_url` = the product page. For `mode: "donation"` products, use `submit_type=donate` and add a small preset-amount picker client-side that posts an `amount` override (validate: 100–50000 cents).
- Insert a `pending` row in `x_orders`; respond `{ url }` and have the client redirect to it.
- If `STRIPE_SECRET_KEY` is unset, respond `503` with a message naming the missing env var.
4. **Webhook route.** `"POST /stripe-webhook"`:
- Verify the `Stripe-Signature` header against `STRIPE_WEBHOOK_SECRET` (HMAC-SHA256 of `{timestamp}.{payload}` per Stripe's scheme — implement it; ~15 lines). Reject on mismatch or >5-minute timestamp skew.
- On `checkout.session.completed`, update the matching `x_orders` row: status `paid`, buyer email from the event. Always respond `200` fast.
- Register the endpoint URL (`{CMS_URL}/api/x/stripe-webhook`) in a code comment + README note for the user to add in the Stripe dashboard.
5. **Frontend.** A product page/section rendering title, image (`?w=800`), description, formatted price, and a Buy button that POSTs `{slug}` to `/api/x/checkout` and redirects to the returned URL. A `/thanks` page confirming the purchase. Disable the button in-flight; surface the 503 message plainly.
6. **Verify.** With Stripe test keys: buy a product with card `4242 4242 4242 4242`, confirm the redirect round-trip, the webhook firing (Stripe CLI `stripe listen --forward-to` in dev), and the `x_orders` row flipping to `paid`.The owner adds and edits products in Studio; unpublishing takes one off sale. Orders are in x_orders (query it, or ask your agent to add a read-only Studio-adjacent report). Refunds and receipts stay in the Stripe dashboard, which remains the financial source of truth.
Privacy-first analytics with a stats card on the Studio dashboard.
Contact form with spam protection and email notifications, plus a submissions inbox in Studio.
Filterable gallery with lightbox, fed straight from the media library.