All pages

Stripe

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.

  1. 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 start sk_live_. Restricted keys start rk_ and are accepted too.

  2. 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 mode has to match: payment for one-time, subscription for recurring, and interval is 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.

  3. 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
        },
      },
      // ...
    }

    unitAmount is 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 priceId of null means the offer is not sold in that environment, and the pricing page renders no card for it rather than a button that fails.

  4. 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_configured while 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.

  5. Register the webhook endpoint

    Under Webhooks, add an endpoint targeting https://<your-api-host>/stripe/webhook and subscribe it to these events:

    Event Why
    checkout.session.completed records the purchase or syncs the subscription
    checkout.session.async_payment_succeeded the only signal that a delayed payment method settled
    customer.subscription.created records a new subscription
    customer.subscription.updated every status change, including cancellation scheduling
    customer.subscription.deleted the cancellation itself
    charge.succeeded sends the receipt
    charge.refunded records the refund, which revokes a fully refunded purchase
    invoice.payment_failed warns a subscriber whose renewal was declined that their access is at risk
    customer.subscription.trial_will_end warns 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_...
  6. 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.

Warning

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

Updated