You need: payments composed with secrets, auth composed, and a Stripe account.
Time: about an hour the first time, per environment.
Stripe is hosted only, and that is a decision
Designing your catalog is what you are selling, entitlements across mobile and web is why the web rail grants the same key as the stores, and refunds and lapses is the after-sale.
Stripe presents the payment page, handles the card, and owns strong customer authentication, tax and receipts. Pithy sends a browser there and hears the outcome on a webhook.
There is no Payment Element, no card fields in your app, and no plan-change or proration logic here. That is a decision rather than a stage: those surfaces change with regulation, and they belong to the company whose job it is to keep up with it.
1. Create products and prices
Under Product catalog, create a product and give it a price. Recurring for a subscription, one-off for anything else.
The price id is what goes in your config — not the product id.
payments({
billingSubject: "user",
rails: { stripe: true },
stripe: {
successUrl: "https://acme.example/thanks?session={CHECKOUT_SESSION_ID}",
cancelUrl: "https://acme.example/pricing",
portalReturnUrl: "https://acme.example/account",
},
products: {
pro_monthly: {
type: "subscription",
name: "Pro",
entitlements: ["pro"],
stripe: { priceId: "price_1Abc" },
},
},
}),The catalog is the only place a price id appears. Gating code names pro.
A price id is publishable by design — it is what a checkout session names, so it may reach a browser. The secret key and the webhook signing secret never do.
Test mode and live mode have different price ids, so your staging and production configs differ.
2. The three return URLs are required
A project that turns the rail on without them fails to parse its config at deploy.
That is deliberate: the alternative is a build that ships, sells nothing, and reports it as a 404 on somebody’s first checkout.
They are config rather than request input, and that is the security part. A client that could name where hosted checkout returns to could send a paying customer to a page it controls.
Put the session-id placeholder somewhere in the success URL’s query. Stripe substitutes the real id when it redirects, and your thank-you page posts it straight back:
await fetch("/payments/purchases", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
rail: "stripe",
receipt: new URLSearchParams(location.search).get("session"),
}),
});Nothing about correctness rests on that call. The webhook produces the identical row through the same idempotent writer. What it buys is the buyer seeing their entitlement on the thank-you page rather than a second or two later.
3. Create the webhook endpoint
Developers → Webhooks, pointing at your Worker’s Stripe webhook path. One per environment.
Subscribe it to these events, and only these:
| Event | What it does |
|---|---|
checkout.session.completed | Binds the Stripe customer to the subject that bought, and projects a one-time purchase |
checkout.session.async_payment_succeeded | A delayed method — bank debit, voucher — finally cleared |
checkout.session.async_payment_failed | It did not |
customer.subscription.created | A subscription began |
customer.subscription.updated | It renewed, lapsed into retry, was paused, or had auto-renew turned off |
customer.subscription.deleted | It ended |
charge.refunded | A one-time purchase was refunded |
Anything else is recorded and ignored, so subscribing to more costs you table rows and nothing else. Subscribing to fewer loses purchases.
Stripe shows the endpoint’s signing secret once.
4. Configure the Billing Portal
Settings → Billing → Customer portal. Configure it and save. Choose what a subscriber may do: cancel, switch plan, update a card, download invoices.
Until you do, the portal route answers 404, with Stripe’s own explanation in the log. That is the failure every adopter hits exactly once, and it is worth doing now rather than at 6pm on launch day.
Those choices live in Stripe rather than in your config on purpose: plan changes and proration are Stripe’s to get right, and a Pithy setting that decided any of them would be Pithy owning them.
5. Store the two credentials
The secret key from Developers → API keys, and the webhook signing secret from the endpoint you just made.
Use a restricted key if you prefer: hosted checkout needs write on checkout sessions and portal sessions, and read on checkout sessions. Nothing else.
Both travel inside one typed secret, alongside any other rail’s block:
pithy secrets create payments-provider-credentials --env prodThe value comes from stdin or a masked prompt. The secret is environment-scoped, which is what keeps a test-mode key and a live one apart.
A rail’s block is present in full or absent entirely, and the schema is checked before the write lands — so half a credential is a refusal in your terminal rather than a signature check that silently never passes.
It is rotatable. Stripe lists a signature per active secret while an endpoint’s secret is being rolled, and a delivery whose second signature matches is accepted — so a rotation drops nothing.
6. Provision the reconciliation Workflow
pithy payments provisionWebhook-only systems rot silently. This deploys the nightly pass that catches what the webhooks missed, and a rising drift count is the signal that they are not arriving.
Starting a checkout
const res = await fetch("/payments/checkout", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ product: "pro_monthly" }),
});
const { url } = await res.json();
location.assign(url);The route names a product, not a price. The catalog maps it.
Reading entitlements
const { entitlements } = await (await fetch("/payments/entitlements")).json();Lapsed rows come back with their flag false rather than filtered out, so a paywall can say your Pro ended on the 4th instead of you are not subscribed — which is a better sentence and a better conversion.
Check it worked
- A test-mode checkout completes and the thank-you page shows the entitlement immediately
- The webhook endpoint shows deliveries succeeding in Stripe’s dashboard
- The portal route opens the portal rather than 404ing
- A refund in test mode revokes the entitlement
pithy payments reconcile --env staging --dry-runreports no drift