Payments
elements man payments Read as markdownTake 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:
- 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_. - Developers, API keys: copy the secret key (
sk_test_...). The app creates Checkout Sessions on the server, so the publishable key is not needed. - Product catalog, Add product: give it a name and a one-time price, then copy
the price id (
price_...). - For local webhooks, install the Stripe CLI and run
stripe login, thenstripe 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:
// 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
// app/shared/stripe.ts
import Stripe from "stripe";
import config from "#config";
export const stripe = new Stripe(config.stripe.secretKey);
The Payments Table
elements create migration "add payments" -tables=payments
Add the columns to the generated table:
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).
// 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<string> {
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:<port> 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:
import "./style.css";
import { redirect } from "@elements/app";
import { startCheckout } from "./services";
async function buy() {
let url = await startCheckout();
redirect(url);
}
<html lang="en" class="page-pricing">
<head>
<title>Pricing</title>
</head>
<body>
<main class="page-shell">
<button class="button is-primary" onclick={() => buy()}>Buy</button>
</main>
</body>
</html>
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.
// 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:
// 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.
// 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:
import "./style.css";
<html lang="en" class="page-checkout-success" (paid: boolean)>
<head>
<title>Thanks</title>
</head>
<body>
<main class="page-shell">
<h1 e:if={paid}>Payment received</h1>
<h1 e:else>Payment processing</h1>
</main>
</body>
</html>
// 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:
- Activate the account for live payments.
- Developers, API keys: copy the live secret key (
sk_live_...). - Create the product and price again in live mode and copy its price id.
- Developers, Webhooks, Add endpoint:
https://<domain>/stripe/webhook, listening forcheckout.session.completedandcheckout.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).