# Payments Take payments with Stripe Checkout, the simplest way to accept a card. Stripe hosts the checkout page and holds the card data, so the app never touches a card number. The app does three things: it creates a Checkout Session and sends the buyer to Stripe, it shows a page when Stripe sends them back, and it records the payment when Stripe calls its webhook. The webhook is the only signal that money moved; the redirect back is not. The official `stripe` npm package works as is: `elements install stripe`. ## What the User Does, What the Agent Writes Some steps happen in the Stripe dashboard and need the account owner. Ask the user for them, in this order, and write the code while they do it. The user, at dashboard.stripe.com: 1. Create a Stripe account, or sign in. Work in a sandbox (test mode) until the app is ready for real money; test keys start with `sk_test_`. 2. Developers, API keys: copy the **secret key** (`sk_test_...`). The app creates Checkout Sessions on the server, so the publishable key is not needed. 3. Product catalog, Add product: give it a name and a one-time price, then copy the **price id** (`price_...`). 4. For local webhooks, install the Stripe CLI and run `stripe login`, then `stripe listen --forward-to localhost:4000/stripe/webhook`. It prints a **webhook signing secret** (`whsec_...`) and must stay running while you test. Use the port the app runs on. Then offer the user a choice for the three values: they paste them into the placeholders in `config/env/development.env` themselves, or they give them to the agent to write there. Either way, the `stripe` block in `config.jsoc` reads them from the env file. The agent writes the config block, the env placeholders, the `payments` migration, the checkout rpc, the webhook route, and the success page below. Never ask the user to paste a live key (`sk_live_...`) into a file that is committed, and never put a secret key in browser-reachable code. ## Keys and Config Secrets are env values, read by `config.jsoc` (`elements man config`). Add a `stripe` block: ```jsoc // config.jsoc { // ... stripe: { secretKey: env!("STRIPE_SECRET_KEY"), webhookSecret: env!("STRIPE_WEBHOOK_SECRET"), priceId: env!("STRIPE_PRICE_ID"), }, } ``` `env!()` makes each value required, so the build fails until all three are set. Ask for the keys before adding the block. Untagged keys stay on the server; nothing here carries `/** @browser */`. Test values go in `config/env/development.env`: ``` STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PRICE_ID=price_... ``` `development.env` is committed by default. Test keys move only test money, but if the repository is shared beyond the team, set these in the shell environment instead: env values come from the process environment first, then the file. Live values go in `config/env/production.env`, which is gitignored. Install the package and create one client for the server: ``` elements install stripe ``` ```ts // app/shared/stripe.ts import Stripe from "stripe"; import config from "#config"; export const stripe = new Stripe(config.stripe.secretKey); ``` ## The Payments Table ```bash elements create migration "add payments" -tables=payments ``` Add the columns to the generated table: ```sql create table payments ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), stripeSessionId text not null unique, userId uuid not null, amountTotal integer not null, currency text not null ); ``` `stripeSessionId` is unique because Stripe retries a webhook until it gets a 2xx, and can deliver the same event more than once. The insert below ignores a repeat. ## Start Checkout Scaffold the two pages with `elements create page pricing` and `elements create page checkout-success`. An rpc creates the Checkout Session and returns its url. The `stripe` package returns promises and does not take part in the automatic await transform, so the rpc is `async` and awaits it (`elements man rpc`). ```ts // app/pages/pricing/services.ts import { getAppUrl, session } from "@elements/app"; import config from "#config"; import { stripe } from "#app/shared/stripe"; /** @rpc */ export async function startCheckout(): Promise { let userId = session.getOrThrow("userId"); let checkout = await stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price: config.stripe.priceId, quantity: 1 }], client_reference_id: userId, success_url: `${getAppUrl()}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${getAppUrl()}/pricing`, }); return checkout.url!; } ``` `client_reference_id` carries the buyer to the webhook. `getAppUrl()` is `http://localhost:` in development and the configured domain in production. Stripe fills in `{CHECKOUT_SESSION_ID}` itself; leave it literal. Because the rpc is declared `async`, the browser call returns a promise. Await it, then leave the page with `redirect()`: `app/pages/pricing/template.html`: ```html import "./style.css"; import { redirect } from "@elements/app"; import { startCheckout } from "./services"; async function buy() { let url = await startCheckout(); redirect(url); } Pricing
``` For a subscription, use a recurring price and `mode: "subscription"`. ## The Webhook Stripe POSTs events to a route. The route verifies the signature against the raw body, which Elements keeps on `req.bodyBuffer` next to the parsed `req.body` (`elements man router`). Verify against `req.bodyBuffer`: the signature covers the exact bytes Stripe sent, and a re-serialized `req.body` does not match them. ```ts // app/pages/stripe-webhook/index.ts import { Request, Response, sql } from "@elements/app"; import config from "#config"; import { stripe } from "#app/shared/stripe"; export default function route(req: Request, res: Response) { let event; try { event = stripe.webhooks.constructEvent( req.bodyBuffer!, req.headers["stripe-signature"] as string, config.stripe.webhookSecret, ); } catch { res.status(400).send("invalid signature"); return; } if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") { let checkout = event.data.object; if (checkout.payment_status === "paid") { sql(` insert into payments (stripeSessionId, userId, amountTotal, currency) values (${checkout.id}, ${checkout.client_reference_id}, ${checkout.amount_total}, ${checkout.currency}) on conflict (stripeSessionId) do nothing `); } } return "ok"; } ``` Register it for POST only: ```ts // index.ts app.route({ method: "post", path: "/stripe/webhook", handler: stripeWebhook }); ``` Grant what was bought here, in the same place the row is written, not on the success page. A handler that throws returns a 500 and Stripe retries the event later. `checkout.session.async_payment_succeeded` covers payment methods that settle after the buyer returns; for a card, `completed` arrives already paid. ## The Success Page Stripe sends the buyer to `success_url`. The webhook may not have arrived yet, so the page asks Stripe for the session's state and says what it knows. It does not grant anything. ```ts // app/pages/checkout-success/index.ts import { Request, Response } from "@elements/app"; import { stripe } from "#app/shared/stripe"; import html from "./template"; export default async function route(req: Request, res: Response) { let checkout = await stripe.checkout.sessions.retrieve(String(req.query.session_id)); return new html({ paid: checkout.payment_status === "paid" }); } ``` `app/pages/checkout-success/template.html`: ```html import "./style.css"; Thanks

Payment received

Payment processing

``` ```ts // index.ts app.route("/pricing", pricing); app.route("/checkout/success", checkoutSuccess); ``` ## Testing With `stripe listen` running, open `/pricing`, click Buy, and pay with the test card `4242 4242 4242 4242`, any future expiry, and any CVC. Then check the row: ``` elements db -sql "select stripe_session_id, amount_total from payments" ``` `stripe trigger checkout.session.completed` sends a synthetic event with no `client_reference_id`, so the insert above rejects it. Test with a real test checkout instead. ## Going Live The user, in the dashboard, with live mode on: 1. Activate the account for live payments. 2. Developers, API keys: copy the live secret key (`sk_live_...`). 3. Create the product and price again in live mode and copy its price id. 4. Developers, Webhooks, Add endpoint: `https:///stripe/webhook`, listening for `checkout.session.completed` and `checkout.session.async_payment_succeeded`. Copy its signing secret. The agent puts the three live values in `config/env/production.env` and deploys (`elements man deploy`).