Payments
Payments lets your app charge money — a one-time purchase or a subscription — through your own Stripe account. The buyer pays on Stripe’s hosted checkout page (no card data ever touches your app), and a verified webhook records each paid order into the project’s own database.
This is your project’s own revenue, and entirely separate from how you pay for Typillar (see Plans & billing). You paste one Stripe secret key; the money lands in your Stripe account.
Paste your Stripe secret key into the console’s Payments tab. That is the whole setup — on the next ship Typillar registers the webhook endpoint in your Stripe account for you, subscribes it to every event Payments needs, and keeps the signing secret. The tab shows you the endpoint it registered, and that endpoint never moves again.
Your secret key is not given to the worker that serves your app, and it is not kept by Typillar either — it goes into a Secrets Store in your own Cloudflare account. What is bound is a handle to it, on a separate worker of this project that runs none of your app’s code and has no public pages — the same one that owns your database. Your app can ask that worker for exactly two things, a checkout and the billing portal, and Cloudflare itself is what permits the call: there is no password between the two to leak, and no address anyone else can reach those two things at.
A restricted key works. The moment you save one, Typillar tests it against every permission Payments needs — write on Checkout Sessions, Billing Portal, Webhook Endpoints, Products, Prices, Coupons and Promotion Codes, and read on Subscriptions — and the tab names anything it can’t do, before a buyer ever hits it. Widen the key in Stripe and press Check to re-test it. If Stripe refuses the webhook registration, the tab says so too, and paid orders are not being recorded until it clears — that is the one failure worth watching for.
Your catalog
Section titled “Your catalog”The Payments tab lists everything this app can sell and creates products for you, so setting
up a price is not a trip to the Stripe dashboard. Give a product a name, a price and a
currency, and pick one-time or subscription with its interval — it is created in your
own Stripe account, and the tab shows the prod_… id that a gated asset or collection checks.
Products you created in Stripe by hand appear in the same list; this is your real catalog, not a copy of it.
Changing a price mints a new price and points the product at it. Stripe prices are immutable, so this never rewrites what an existing subscriber pays — they keep the price they signed up at until they resubscribe. The live app also keeps selling the old price until its next ship, because the price id is baked into the app’s code when it is built.
Archiving a product stops it being sold and drops it from the catalog. It does not refund anyone, does not cancel a subscription, and does not take away access anybody already bought — an entitlement outlives the product it came from. Cancelling is the buyer’s own job through the billing portal.
The agent can read this catalog and can never add to it. That is deliberate: a product is what entitlements key off, so a price has to be a number you chose, not one a model typed.
Discount codes
Section titled “Discount codes”The same tab creates the code a buyer types at checkout. Give it a name like LAUNCH20, a
discount — a percentage or a fixed amount in one currency — and optionally a limit on how many
times it can be redeemed. Typillar makes the Stripe coupon and the promotion code together, so
one code is one row here.
First payment or every payment only means something to a subscription: once discounts
the first invoice, forever discounts every invoice for as long as the subscription runs. A
one-time purchase is a single payment either way.
Turning a code off stops new redemptions. Anyone already subscribed with it keeps the discount they redeemed — Stripe attached it to their subscription, not to the code.
A code changes what the buyer pays, never what they bought: the entitlement still lands on the product. That is why the agent is allowed to switch the discount box on and never allowed to mint the code behind it.
The box only appears at checkout when the buy button asks for it — that is promoCodes: true on
the checkout() call. Ask the agent to let people enter a discount code and it sets that.
The API
Section titled “The API”| Method & path | Body | Returns |
|---|---|---|
POST /api/_pay/checkout |
{"price":"price_..."} or {"amount":<smallest unit>,"currency?":"usd","name":"Pro plan","quantity?":1,"mode?":"payment","interval?":"month","promoCodes?":true,"successUrl?":"/thanks","cancelUrl?":"/"} |
201 {"url":"https://checkout.stripe.com/...","id"} |
GET /api/_pay/orders |
— | {"items":[{"id","sessionId","amountTotal","currency","status","mode","email","products","createdAt"}]} |
GET /api/_pay/entitlements |
— | {"items":[{"productId","priceId","orderId","subscriptionId","expiresAt","createdAt"}]} |
POST /api/_pay/portal |
{"returnUrl?":"/account"} |
201 {"url":"https://billing.stripe.com/..."} |
Orders and entitlements are read-only to your app; the webhook is Stripe’s, not yours.
Your app doesn’t call those routes by hand. It imports them, the same way it imports sign-in and data:
import { checkout, entitledTo, entitlements, orders, manageBilling } from "pay";The table above is what the app’s server.mjs speaks, through env.PLATFORM.fetch — it is a
lone module with no module graph, so it can’t import anything.
Starting a checkout
Section titled “Starting a checkout”Build a price inline — no need to pre-create products in Stripe — and checkout() takes the
buyer to Stripe’s payment page itself:
await checkout({ amount: 1500, // smallest currency unit — 1500 = $15.00 currency: "usd", name: "Pro plan", // mode: "subscription", interval: "month", // for recurring successUrl: "/thanks", cancelUrl: "/pricing",});There is no URL to read back and no redirect to write: checkout() creates the Stripe session
and leaves the page. successUrl is where Stripe brings the buyer back.
| Field | |
|---|---|
price |
a Stripe price id from your catalog — see below |
amount |
integer, smallest currency unit (e.g. cents) — required unless you pass price |
currency |
default usd |
name |
line-item name shown at checkout |
quantity |
1–100 (default 1) |
mode |
payment (default) or subscription |
interval |
for subscriptions: day, week, month (default), year |
promoCodes |
true shows a discount-code box at checkout — codes come from the Payments tab |
successUrl, cancelUrl |
where Stripe returns the buyer |
metadata |
arbitrary key/values, echoed onto the order |
Checkout is not gated on sign-in — anyone should be able to pay you — but when a user is signed in, the order is attributed to them and their email is prefilled on the Stripe page. A discount changes the price, never what was bought: a promo code applied to a product checkout still entitles the buyer to that product.
Selling something specific
Section titled “Selling something specific”An inline amount is named by the browser, so what it bought can never be trusted — a
visitor can post you a checkout for one cent. That’s fine for a donate button, and useless as
a paywall. An inline-amount purchase therefore never grants an entitlement — its order is
recorded, and that’s all.
To sell a thing, create the product in the Payments tab and pass its price id instead. Stripe holds the amount; the app can’t rewrite it:
await checkout({ price: "price_1AbC…", successUrl: "/thanks" });You don’t have to look the price id up yourself. The agent reads your Stripe catalog directly, so “sell this for the Pro plan price” is enough — it finds the product, wires checkout to the right price, and can’t invent an id that isn’t in your account. If the catalog is empty, it asks you to add the product in the Payments tab rather than guessing.
When a signed-in buyer completes that checkout, the webhook grants them an entitlement to the product — which is what a gated asset and a gated collection check. This is the whole paid-content path: Stripe product → entitlement → the file opens, the collection answers.
A recurring price works the same way. Sell a subscription and the buyer is entitled to its product for as long as they keep paying.
An entitlement is a lease
Section titled “An entitlement is a lease”An entitlement carries the date it is paid through (expiresAt), and access is checked
against it. A one-time purchase has no expiry — expiresAt is null and the buyer owns it
outright. A subscription’s entitlement is paid through the end of the current period plus a
24-hour grace, and each renewal extends it. The grace is there so a renewal that Stripe
takes a few minutes to confirm never locks a paying customer out of what they just paid for.
This is deliberate: a subscription that stops renewing closes the content on its own, because nothing came along to extend the lease. Access never depends on a webhook arriving. A cancellation, a failed renewal, or a full refund revokes the entitlement immediately when the event lands — but if that event is ever lost, the worst case is that a buyer keeps access for the remainder of a period they had already paid for. That is the failure you want, and it is why gating on a subscription is safe here.
if (await entitledTo("prod_1AbC…")) showTheCourse();
const owned = await entitlements();// [ { productId, priceId, orderId, subscriptionId, expiresAt, createdAt, updatedAt }, … ]// only LIVE entitlements come back — an expired one is already goneentitlements() answers the array itself, read once and cached for the page —
refreshEntitlements() re-reads it. A signed-out visitor owns nothing, so it answers []
rather than throwing.
A partial refund is treated as a discount after the fact, not a repossession — only a full refund takes the product back. And a product owned outright is never repossessed by cancelling a subscription that happened to include it.
Cancelling — the billing portal
Section titled “Cancelling — the billing portal”A subscriber must be able to leave without emailing you. manageBilling() (signed-in only)
sends the buyer to Stripe’s hosted billing portal — cancel the subscription, update the
card, download invoices — leaving the page exactly like checkout() does. returnUrl is
where Stripe sends them back:
await manageBilling({ returnUrl: "/account" });The portal is scoped to the buyer’s own Stripe customer, which a subscription checkout
creates; a buyer with no billing profile gets a PayError with status 404. In live
mode Stripe requires the portal to be configured once — save its settings under Stripe →
Settings → Billing → Customer portal, or the call throws Stripe’s error.
Cancellation still plays by the lease rules: the entitlement runs to the end of the period already paid for, then lapses on its own.
Sales tax & VAT
Section titled “Sales tax & VAT”Turn on Collect sales tax in the console’s Payments tab and every checkout is priced with Stripe Tax: Stripe collects the buyer’s location, calculates the tax, and adds it on top (inline amounts are treated as tax-exclusive). This requires Stripe Tax to be set up in your Stripe dashboard — registrations and an origin address — and checkout fails with Stripe’s error message if it isn’t. Takes effect on the next ship.
Orders (the webhook)
Section titled “Orders (the webhook)”Never trust a client “success” redirect for fulfilment. Payment is confirmed server-side: Stripe posts to the worker that holds your key — never to your app — the request is HMAC-verified against your webhook signing secret, and only then is the order recorded against the buyer. The order is keyed by its Stripe checkout session, so a replayed webhook updates it rather than recording it twice.
A slow payment method (a bank debit) completes checkout before the money clears: its
order is recorded unpaid and no entitlement is granted until Stripe confirms the payment,
at which point the order flips to paid and the grant lands. A debit that never clears is
stamped failed.
You don’t subscribe the endpoint to anything by hand — Typillar registers it with the full event set and repairs it on every ship if it drifts. That matters more than it sounds: renewals and refunds arrive as their own events, so an endpoint subscribed to checkout alone would quietly let every subscription lease lapse a day after its first period.
Read the signed-in buyer’s orders:
const bought = await orders(50);// → [ { id, sessionId, amountTotal, currency, status, mode, email, products, … }, … ]
const owned = await entitlements();// → [ { productId, priceId, orderId, expiresAt, createdAt, updatedAt }, … ]Both answer the array itself — there is no items wrapper to unpack — and each buyer sees
only their own. Both are read-only, and both answer [] for a signed-out visitor rather than
throwing, so ask sign-in who is signed in, not Payments.
orders(limit) takes a limit — default 50, max 200, newest first.
Fulfil against orders(), not against the landing page Stripe returns to.
Orders and entitlements are read-only to your app, and they are not
Data collections. They have fixed shapes, so they get tables of their
own in the project’s database: Stripe’s verified webhook is their only writer, and there is
no endpoint — under /api/_data or anywhere else — that lets the app write one. Without
that, a buyer could simply post themselves the entitlement and take the goods.
A purchase made while signed out has no one to belong to, so it’s recorded with no buyer and the app never serves it to anyone. If you want buyers to see their own orders, ask them to sign in before they pay.
How it’s built
Section titled “How it’s built”Payments talks directly to Stripe’s API — no SDK bundled — from the private worker beside your app, using your Stripe secret key and the webhook signing secret Stripe issued when the endpoint was registered. Neither is kept by Typillar: both live in a Secrets Store in your own Cloudflare account, and that worker is the only thing anywhere that can read them (see your data & secrets). Managing your catalog from the Payments tab is that worker doing the work and answering the console.
Orders and entitlements are kept in the project’s own database (the same D1 that Data uses, in tables of their own). Payments provisions that shared database when necessary, but it does not turn on Data’s app-facing record API. Just ask the agent for checkout, then supply the Stripe secret key when it asks.