The catalog in packages/shared/src/catalog.ts ships with FFD's own price ids. No Stripe key ships, not even the sandbox's, so the first thing you do here is replace those literals with prices in your own account.
You will leave with a secret key, a webhook signing secret, and one price id per offer you sell.
During the agent-run setup, the agent inventories the selected Stripe account before it creates products, prices, and webhook endpoints through the CLI or API. You handle Stripe login, account selection, and any approval required to reveal or create the durable runtime key. The agent writes that key and the returned resource values directly to the ignored environment file.
-
Work in the sandbox first
Stripe's sandbox is an isolated environment with its own keys and price ids. Which environment this api is talking to is derived from the secret key's prefix and never from
NODE_ENV, so switching environments is switching one variable.Sandbox keys start
sk_test_. Live keys startsk_live_. Restricted keys startrk_and are accepted too. -
Create a product and a price
Under Product catalog, create a product, then add a price to it.
For a one-time offer choose a one-off price. For a recurring one choose a recurring price and a billing period. The catalog's
modehas to match:paymentfor one-time,subscriptionfor recurring, andintervalis required for the second and forbidden for the first, so a mismatch is a type error rather than a surprise at checkout.Copy the price id, which starts
price_. The product id is not used anywhere in this repo. -
Put the price ids in the catalog
export const CATALOG: Readonly<Record<Slug, CatalogEntry>> = { pro: { mode: 'payment', features: ['boilerplate', 'lifetime-updates'], unitAmount: 16_900, currency: 'usd', priceId: { live: 'price_...', // yours test: 'price_...', // yours }, }, // ... }unitAmountis minor units and is what the pricing page renders. It is not what a customer is charged: the charge is whatever the Stripe price says. Keeping them in step is on you, and the number in the catalog is the one your storefront claims.A
priceIdofnullmeans the offer is not sold in that environment, and the pricing page renders no card for it rather than a button that fails. -
Copy the secret key
The durable runtime key may require the browser's Developers, API keys page and an account-owner approval. After that handoff, the agent stores the secret key in
apps/api/.env:STRIPE_SECRET_KEY=sk_test_...Only the api gets this. The web app renders prices from the static catalog, so no Stripe key is anywhere near a build.
Leave it unset and the checkout and webhook routes answer 501
not_configuredwhile the rest of the app runs. If you get Stripe's own authentication error instead, the key is set and mistyped, which is a different failure on purpose. -
Register the webhook endpoint
Under Webhooks, add an endpoint targeting
https://<your-api-host>/stripe/webhookand subscribe it to these events:Event Why checkout.session.completedrecords the purchase or syncs the subscription checkout.session.async_payment_succeededthe only signal that a delayed payment method settled customer.subscription.createdrecords a new subscription customer.subscription.updatedevery status change, including cancellation scheduling customer.subscription.deletedthe cancellation itself charge.succeededsends the receipt charge.refundedrecords the refund, which revokes a fully refunded purchase invoice.payment_failedwarns a subscriber whose renewal was declined that their access is at risk customer.subscription.trial_will_endwarns a trialing subscriber what the first charge will be, three days out Subscribing to more is harmless: unhandled types are acknowledged and ignored, because answering them 4xx makes Stripe mark the endpoint unhealthy and back off the ones that matter.
Copy the endpoint's signing secret into
apps/api/.env:STRIPE_WEBHOOK_SECRET=whsec_... -
Turn off Stripe's own failed-payment emails
Turn off Send emails when card payments fail in your revenue recovery email settings, at
https://dashboard.stripe.com/revenue_recovery/emails. The api sends every mail whose subject is access, so leaving Stripe's on means a subscriber hears about one declined renewal from two senders, with two different links to update the same card.The other subscription emails on that page and under Email notifications and customer management stay off for the same reason. See
docs/adr/0032-mail-follows-entitlement-not-invoices.md.Stripe does not send customer emails from a sandbox unless the customer's address belongs to your verified email domain or an active team member, so a sandbox that looks quiet is not proof the setting is off.
Create a customer portal configuration
POST /user/billing-portal mints a Stripe Customer Portal session, which is where a customer updates a card or cancels a subscription. This repo ships no endpoint of its own for either, because customer.subscription.updated already records whatever the customer does there.
Stripe needs a portal configuration on the account first. Without one, every session creation answers an error instead of returning a URL. The setup sequence creates it with billingPortal.configurations.create, allowing payment-method update and subscription cancellation at minimum, and writes the returned id to apps/api/.env:
STRIPE_PORTAL_CONFIGURATION_ID=bpc_...
That id is not optional decoration. is_default is not a creation parameter, so a configuration created through the API is never the account default, and a session created without an id uses the default configuration rather than the one you just made. Leave the variable unset only when the default configuration, which is the one the dashboard manages, is the one you want.
The return URL is built from WEB_URL, like the checkout redirects.
Forward events to localhost
stripe listen --forward-to localhost:5001/stripe/webhook
The CLI prints a signing secret for that session, which is different from the dashboard endpoint's. Use it in your local .env while the listener is running.
:::
Five things this checkout does that you should know about
It requires a signed-in caller. POST /checkout/session sits behind requireUser, and the web app sends a signed-out reader to sign-up before the step at /checkout that confirms the order. The Session is created with a Stripe Customer that belongs to that account, which is what makes the charge email non-editable on Stripe's page. The reasoning is in docs/adr/0031-identity-precedes-payment.md.
It takes a slug and never a price id. The client posts { slug: 'creator-monthly', locale: 'en' } and the api resolves the price. A caller who could name a price could pay for the cheapest offer in your account and ask for the most expensive one, which is a live exploit the reference implementation shipped. The locale selects the checkout language and is saved with a completed Purchase for later emails; it cannot change the price.
It sets allow_promotion_codes. That is the real discount mechanism, and it means the amount actually charged can differ from the catalog's list price. Subscriptions record what the latest invoice says was paid rather than what the price says, for exactly this reason.
It does not pin payment_method_types. Left unset, Stripe offers every method your account has turned on. Pinning it to ['card'] silently overrides your own dashboard settings.
Success and cancel URLs are built from WEB_URL, never from the request's Origin header, which is caller-controlled. Set WEB_URL to your web app's origin with no trailing slash.
FFD's live Stripe account carries 66 card-testing charges and zero completed sales, every one a small validation charge against an unauthenticated, unvalidated, unlimited checkout endpoint. /checkout in this repo now requires a session and is rate limited to 20 requests per 15 minutes per IP for that reason. The counts live in the rate_limit_hits Postgres table, so every api instance enforces the same limit.
Going live
Switch the secret key to the live one and make sure the catalog's live price ids are filled in. The api derives its environment from the key, and the web app is told through VITE_STRIPE_ENV, which is replaced in browser code at build, so changing it means a rebuild.
GET /health reports the api's answer. If the two disagree, a checkout for a slug with no live price answers 503 stripe_env_mismatch rather than failing silently.
Keys you now hold
| Value | Goes in | As |
|---|---|---|
| Secret key | apps/api/.env |
STRIPE_SECRET_KEY |
| Webhook signing secret | apps/api/.env |
STRIPE_WEBHOOK_SECRET |
| Customer portal configuration id | apps/api/.env |
STRIPE_PORTAL_CONFIGURATION_ID |
| Price ids | packages/shared/src/catalog.ts |
priceId.live and priceId.test |
| Your web app's origin | apps/api/.env |
WEB_URL |
live or test |
apps/web/.env.development |
VITE_STRIPE_ENV |
Related
- The billing model, what these objects mean once they exist
- Pricing catalog, every field on a catalog entry
docs/adr/0012-slug-major-catalog-carries-prices.mddocs/adr/0013-api-hardening-baseline.md- Stripe CLI
- Stripe webhooks
