# Payments Setup One env value turns payments on. It is optional, so the app builds without it. ## Config ```jsoc // config.jsoc { // ... stripe: { secretKey: env("STRIPE_SECRET_KEY", ""), // Development only, printed by `stripe listen`. Production registers its // own endpoint and stores the secret (see `payments/webhook`). webhookSecret: env("STRIPE_WEBHOOK_SECRET", ""), }, } ``` Put empty placeholders in `config/env/development.env`, and nothing else: ``` STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= ``` `development.env` is committed, so the owner's key goes in their copy, or in the shell environment, which env values read first. An edit to the env file rebuilds the config and hot reloads the app and its open pages, so the setup page below turns green the moment the key is saved. Live keys go in `config/env/production.env`, which is gitignored. ## The Client ```ts // app/shared/stripe.ts import Stripe from "stripe"; import config from "#config"; let client: Stripe | undefined; export function stripeConfigured(): boolean { return config.stripe.secretKey !== ""; } export function stripe(): Stripe { client ??= new Stripe(config.stripe.secretKey); return client; } export function stripeMode(): "sandbox" | "live" { return config.stripe.secretKey.startsWith("sk_live_") ? "live" : "sandbox"; } ``` Call `stripeConfigured()` before any Stripe call. Pages that sell something pass it to their template, which shows the buy button when it is true and "Payments are not set up yet" when it is false, with a link to `/admin/payments` for admins. ## The Setup Page Build this page in every app that takes payments, even when the request only says "payments by Stripe". Without it the owner has no way to see whether payments work. `elements create page admin-payments`, routed at `/admin/payments` and gated to admins (`elements man recipes/admin-roles`). The route asks Stripe who the key belongs to. A wrong key is a status, not a crash. ```ts // app/pages/admin-payments/index.ts import { Request, Response, getAppUrl, getEnv } from "@elements/app"; import config from "#config"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; import { stripe, stripeConfigured, stripeMode } from "#app/shared/stripe"; import { ensureWebhook } from "#app/shared/stripe-webhook"; import html, { PaymentsStatus } from "./template"; export default async function route(req: Request, res: Response) { isUserAdminOrThrow(); let status: PaymentsStatus = { configured: stripeConfigured(), mode: stripeMode(), accountName: "", keyError: "", development: getEnv() === "development", webhookUrl: `${getAppUrl()}/stripe/webhook`, webhookReady: false, }; if (status.configured) { try { let account = await stripe().accounts.retrieveCurrent(); status.accountName = account.settings?.dashboard?.display_name ?? account.id; status.webhookReady = status.development ? config.stripe.webhookSecret !== "" : await ensureWebhook(); } catch (err) { status.keyError = (err as Error).message; } } return new html({ status }); } ``` The template is a checklist. Each step shows done or what to do next: 1. **Stripe key.** When unset: "Create a free Stripe account at dashboard.stripe.com/register. You can use a sandbox right away, with no business details. Open Developers, API keys, copy the secret key (`sk_test_...`), and add it to `config/env/development.env` as `STRIPE_SECRET_KEY=`." When set: "Connected to {accountName}", with a Sandbox or Live badge, or `keyError` with the same instructions again. 2. **Prices.** Always done: "Prices come from this app. Nothing to create in Stripe." 3. **Webhook.** In development, optional: returning buyers are fulfilled without it. To test it, show the two commands: `stripe login`, then `stripe listen --forward-to {webhookUrl}`, and to add the `whsec_...` it prints as `STRIPE_WEBHOOK_SECRET=`. In production: "Registered at {webhookUrl}" once `ensureWebhook()` returns true. 4. **Test payment.** A button that starts a real Checkout for a $1 test item, with the hint "Card 4242 4242 4242 4242, any future date, any CVC." Only shown in sandbox mode. Record it apart from real sales, in its own table or with a test flag on the payments row, so it never becomes an order, a donation or revenue. Tag the session (`metadata: { kind: "test" }`) and have `fulfillCheckout` and the webhook record test sessions there. Stripe sends the admin back to `/admin/payments`, which shows the last test payment as paid. Link the page from the admin navigation, and from wherever a buy button says payments are not set up. ## Related - `payments/checkout`: the checkout rpc and fulfillment. - `payments/webhook`: `ensureWebhook()` and the webhook route. - `config`: env files and `getEnv()`.