Sell a subscription
You have a product with a monthly price and you want people to pay for it. The shape every SaaS lands on is:
- Checkout — your app creates a Stripe Customer and a hosted Checkout Session, then redirects the buyer to Stripe’s payment page.
- Webhooks — Stripe tells you what happened (
paid,renewed,card declined,canceled). Your app writes that to its own store. - Entitlement — the rest of your app reads that store to decide whether to serve paid features. It never calls Stripe on the request path.
- Billing Portal — a customer who wants to change their card or cancel gets sent to Stripe’s hosted portal. You don’t build those screens.
This guide walks through examples/stripe-billing, which does exactly
that on one Worker. Every snippet below is a piece of
src/Api.ts;
the whole file is at the end.
The catalog lives on the Worker
Section titled “The catalog lives on the Worker”The Product, its Price, a launch Coupon, and the Billing Portal configuration are Stack resources declared inside the Worker’s Effect. They deploy with the Worker and are destroyed with it.
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const product = yield* Stripe.Product("Pro", { name: "Pro", description: "Billed monthly", }); const price = yield* Stripe.Price("ProMonthly", { product, currency: "usd", unitAmount: 2000, recurring: { interval: "month" }, }); yield* Stripe.Coupon("Launch20", { percentOff: 20, duration: "once", name: "Launch 20%", });
const portalConfig = yield* Stripe.BillingPortalConfiguration("Portal", { name: "Pro customer portal", features: { invoiceHistory: { enabled: true }, paymentMethodUpdate: { enabled: true }, customerUpdate: { enabled: true, allowedUpdates: ["email"] }, subscriptionCancel: { enabled: true, mode: "at_period_end" }, }, }); // ...Price takes the Product directly — no id plumbing.
Carry ids from plan time to runtime
Section titled “Carry ids from plan time to runtime”price.id is an Output — it doesn’t have a value until the Price is
created. Yielding an Output inside the Worker’s Effect gives you an
accessor you can yield again from a request handler:
const priceId = yield* price.id; const portalConfigId = yield* portalConfig.id;That’s the whole mechanism for getting a resource’s id into a route. There is no environment variable to name and no string to interpolate.
Bind the Stripe calls the routes make
Section titled “Bind the Stripe calls the routes make”Three HTTP bindings, one per Stripe call the Worker will make at
runtime. Each one adds its permission (customers_write,
checkout_sessions_write, billing_portal_write) to the Worker’s
Stripe token and returns a callable:
const createCustomer = yield* Stripe.CreateCustomer(); const createCheckout = yield* Stripe.CreateCheckoutSession(); const createPortal = yield* Stripe.CreateBillingPortalSession();And the store the webhook handler writes to:
const entitlements = yield* Cloudflare.KV.Namespace("Entitlements"); const kv = yield* Cloudflare.KV.ReadWriteNamespace(entitlements);Subscribe to what Stripe tells you
Section titled “Subscribe to what Stripe tells you”consumeEvents provisions the WebhookEndpoint pointed at this
Worker, enables exactly the events you list, and binds the signing
secret. The handler runs once per verified delivery with a typed
event.
yield* Stripe.consumeEvents( "Events", { events: [ Stripe.CheckoutSessionCompleted, Stripe.CustomerSubscriptionCreated, Stripe.CustomerSubscriptionUpdated, Stripe.CustomerSubscriptionDeleted, Stripe.InvoicePaymentFailed, ], }, Effect.fn(function* (event) { const write = (entitlement: Entitlement) => kv .put(entitlement.customerId, JSON.stringify(entitlement)) .pipe(Effect.orDie);
switch (event.type) { case "checkout.session.completed": { const session = event.object; const customerId = idOf(session.customer); if (customerId === null) return; yield* write({ customerId, status: "active", priceId: yield* priceId, subscriptionId: idOf(session.subscription), updatedAt: Date.now(), }); return; } case "customer.subscription.created": case "customer.subscription.updated": { const subscription = event.object; yield* write({ customerId: idOf(subscription.customer)!, status: subscription.status === "active" || subscription.status === "trialing" ? "active" : subscription.status === "past_due" ? "past_due" : "canceled", priceId: subscription.items.data[0]?.price.id ?? null, subscriptionId: subscription.id, updatedAt: Date.now(), }); return; } case "customer.subscription.deleted": { // ... status: "canceled" } case "invoice.payment_failed": { // ... status: "past_due" — Stripe retries; nudge to the portal } } }), );The Entitlement record is deliberately small — customer, status, which
price, which subscription. It’s the only thing the rest of your app
needs.
The routes
Section titled “The routes”Everything above is plan time. The fetch handler is what runs per
request.
POST /checkout — start a subscription
Section titled “POST /checkout — start a subscription”Create the Customer first so every webhook that follows carries a
stable cus_… id you can key on, then create the Checkout Session
for the deployed Price and hand back the hosted URL:
if (request.method === "POST" && url.pathname === "/checkout") { const body = (yield* request.json) as { email?: string }; if (!body.email) { return yield* HttpServerResponse.json( { error: "email is required" }, { status: 400 }, ); }
const customer = yield* createCustomer({ email: body.email }).pipe( Effect.orDie, ); const session = yield* createCheckout({ mode: "subscription", customer: customer.id, line_items: [{ price: yield* priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${url.origin}/welcome?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${url.origin}/pricing`, }).pipe(Effect.orDie);
return yield* HttpServerResponse.json( { customerId: customer.id, checkoutUrl: session.url }, { status: 201 }, ); }Your frontend redirects to checkoutUrl. Stripe collects the card and
sends the buyer to success_url.
POST /portal — let them manage it
Section titled “POST /portal — let them manage it” if (request.method === "POST" && url.pathname === "/portal") { const body = (yield* request.json) as { customerId?: string }; // ... const session = yield* createPortal({ customer: body.customerId, configuration: yield* portalConfigId, return_url: `${url.origin}/account`, }).pipe(Effect.orDie); return yield* HttpServerResponse.json({ portalUrl: session.url }); }The portal honours the BillingPortalConfiguration declared above:
invoice history, card update, email update, cancel at period end.
GET /subscription/:customerId — what to gate on
Section titled “GET /subscription/:customerId — what to gate on” if ( request.method === "GET" && url.pathname.startsWith("/subscription/") ) { const customerId = url.pathname.slice("/subscription/".length); const entitlement = yield* kv .get<Entitlement>(customerId, "json") .pipe(Effect.orDie); return yield* HttpServerResponse.json( entitlement ?? { customerId, status: "none" }, ); }This is a KV read. It’s what your app calls on every authenticated request to decide what to show.
Provide the layers
Section titled “Provide the layers”The Worker Effect closes by providing every binding’s implementation in
one Effect.provide([...]):
}).pipe( Effect.provide([ Cloudflare.KV.ReadWriteNamespaceBinding, Stripe.CreateCustomerHttp, Stripe.CreateCheckoutSessionHttp, Stripe.CreateBillingPortalSessionHttp, Stripe.ConsumeEventsLive, ]), ),) {}Try it
Section titled “Try it”cd examples/stripe-billingbun alchemy deploycurl -X POST "$URL/checkout" -H 'content-type: application/json' \ -d '{"email":"you@example.com"}'# → { "customerId": "cus_…", "checkoutUrl": "https://checkout.stripe.com/…" }Open checkoutUrl in a browser and pay with Stripe’s test card
4242 4242 4242 4242. Within a few seconds:
curl "$URL/subscription/cus_…"# → { "customerId": "cus_…", "status": "active", "priceId": "price_…", … }Cancel from the portal (POST /portal → open portalUrl) and the same
call returns "status": "canceled".
The integration test in test/integ.test.ts runs this loop without a
browser by attaching Stripe’s tok_visa test card and creating the
subscription directly, then asserting the webhook-driven entitlement
reaches active and then canceled.
Full file
Section titled “Full file”examples/stripe-billing/src/Api.ts
import * as Cloudflare from "alchemy/Cloudflare";import * as Stripe from "alchemy/Stripe";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
interface Entitlement { customerId: string; status: "active" | "past_due" | "canceled"; priceId: string | null; subscriptionId: string | null; updatedAt: number;}
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url, }, Effect.gen(function* () { const product = yield* Stripe.Product("Pro", { name: "Pro", description: "Billed monthly", }); const price = yield* Stripe.Price("ProMonthly", { product, currency: "usd", unitAmount: 2000, recurring: { interval: "month" }, }); yield* Stripe.Coupon("Launch20", { percentOff: 20, duration: "once", name: "Launch 20%", });
const portalConfig = yield* Stripe.BillingPortalConfiguration("Portal", { name: "Pro customer portal", features: { invoiceHistory: { enabled: true }, paymentMethodUpdate: { enabled: true }, customerUpdate: { enabled: true, allowedUpdates: ["email"] }, subscriptionCancel: { enabled: true, mode: "at_period_end" }, }, });
const priceId = yield* price.id; const portalConfigId = yield* portalConfig.id;
const createCustomer = yield* Stripe.CreateCustomer(); const createCheckout = yield* Stripe.CreateCheckoutSession(); const createPortal = yield* Stripe.CreateBillingPortalSession();
const entitlements = yield* Cloudflare.KV.Namespace("Entitlements"); const kv = yield* Cloudflare.KV.ReadWriteNamespace(entitlements);
yield* Stripe.consumeEvents( "Events", { events: [ Stripe.CheckoutSessionCompleted, Stripe.CustomerSubscriptionCreated, Stripe.CustomerSubscriptionUpdated, Stripe.CustomerSubscriptionDeleted, Stripe.InvoicePaymentFailed, ], }, Effect.fn(function* (event) { const write = (entitlement: Entitlement) => kv .put(entitlement.customerId, JSON.stringify(entitlement)) .pipe(Effect.orDie);
switch (event.type) { case "checkout.session.completed": { const session = event.object; const customerId = idOf(session.customer); if (customerId === null) return; yield* write({ customerId, status: "active", priceId: yield* priceId, subscriptionId: idOf(session.subscription), updatedAt: Date.now(), }); return; } case "customer.subscription.created": case "customer.subscription.updated": { const subscription = event.object; yield* write({ customerId: idOf(subscription.customer)!, status: subscription.status === "active" || subscription.status === "trialing" ? "active" : subscription.status === "past_due" ? "past_due" : "canceled", priceId: subscription.items.data[0]?.price.id ?? null, subscriptionId: subscription.id, updatedAt: Date.now(), }); return; } case "customer.subscription.deleted": { const subscription = event.object; yield* write({ customerId: idOf(subscription.customer)!, status: "canceled", priceId: null, subscriptionId: subscription.id, updatedAt: Date.now(), }); return; } case "invoice.payment_failed": { const invoice = event.object; const customerId = idOf(invoice.customer); if (customerId === null) return; const current = yield* kv .get<Entitlement>(customerId, "json") .pipe(Effect.orDie); yield* write({ customerId, status: "past_due", priceId: current?.priceId ?? null, subscriptionId: current?.subscriptionId ?? null, updatedAt: Date.now(), }); return; } } }), );
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const url = new URL(request.originalUrl);
if (request.method === "POST" && url.pathname === "/checkout") { const body = (yield* request.json) as { email?: string }; if (!body.email) { return yield* HttpServerResponse.json( { error: "email is required" }, { status: 400 }, ); }
const customer = yield* createCustomer({ email: body.email }).pipe( Effect.orDie, ); const session = yield* createCheckout({ mode: "subscription", customer: customer.id, line_items: [{ price: yield* priceId, quantity: 1 }], allow_promotion_codes: true, success_url: `${url.origin}/welcome?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${url.origin}/pricing`, }).pipe(Effect.orDie);
return yield* HttpServerResponse.json( { customerId: customer.id, checkoutUrl: session.url }, { status: 201 }, ); }
if (request.method === "POST" && url.pathname === "/portal") { const body = (yield* request.json) as { customerId?: string }; if (!body.customerId) { return yield* HttpServerResponse.json( { error: "customerId is required" }, { status: 400 }, ); } const session = yield* createPortal({ customer: body.customerId, configuration: yield* portalConfigId, return_url: `${url.origin}/account`, }).pipe(Effect.orDie); return yield* HttpServerResponse.json({ portalUrl: session.url }); }
if ( request.method === "GET" && url.pathname.startsWith("/subscription/") ) { const customerId = url.pathname.slice("/subscription/".length); const entitlement = yield* kv .get<Entitlement>(customerId, "json") .pipe(Effect.orDie); return yield* HttpServerResponse.json( entitlement ?? { customerId, status: "none" }, ); }
if (url.pathname === "/welcome" || url.pathname === "/pricing") { return HttpServerResponse.text( url.pathname === "/welcome" ? "Thanks — your subscription is active." : "Pricing page.", ); }
return yield* HttpServerResponse.json( { error: "Not found" }, { status: 404 }, ); }), }; }).pipe( Effect.provide([ Cloudflare.KV.ReadWriteNamespaceBinding, Stripe.CreateCustomerHttp, Stripe.CreateCheckoutSessionHttp, Stripe.CreateBillingPortalSessionHttp, Stripe.ConsumeEventsLive, ]), ),) {}
const idOf = ( ref: string | { id: string } | { id?: string } | null | undefined,): string | null => ref == null ? null : typeof ref === "string" ? ref : (ref.id ?? null);- Connect — onboard other businesses and pay them out.
- BillingPortalConfiguration, Price, WebhookEndpoint reference.