1---
2title: Sell a subscription
3description: The full SaaS billing loop on one Cloudflare Worker — hosted Checkout, the Billing Portal, and webhooks that keep an entitlement record your app gates on.
4---
5
6You have a product with a monthly price and you want people to pay for
7it. The shape every SaaS lands on is:
8
91. **Checkout** — your app creates a Stripe Customer and a hosted
10 Checkout Session, then redirects the buyer to Stripe's payment page.
112. **Webhooks** — Stripe tells you what happened (`paid`, `renewed`,
12 `card declined`, `canceled`). Your app writes that to its own store.
133. **Entitlement** — the rest of your app reads that store to decide
14 whether to serve paid features. It never calls Stripe on the request
15 path.
164. **Billing Portal** — a customer who wants to change their card or
17 cancel gets sent to Stripe's hosted portal. You don't build those
18 screens.
19
20This guide walks through `examples/stripe-billing`, which does exactly
21that on one Worker. Every snippet below is a piece of
22[`src/Api.ts`](https://github.com/alchemy-run/alchemy/blob/main/examples/stripe-billing/src/Api.ts);
23the whole file is at the end.
24
25## The catalog lives on the Worker
26
27The Product, its Price, a launch Coupon, and the Billing Portal
28configuration are Stack resources declared *inside* the Worker's
29Effect. They deploy with the Worker and are destroyed with it.
30
31```typescript
32// src/Api.ts
33export default class Api extends Cloudflare.Worker<Api>()(
34 "Api",
35 { main: import.meta.url },
36 Effect.gen(function* () {
37 const product = yield* Stripe.Product("Pro", {
38 name: "Pro",
39 description: "Billed monthly",
40 });
41 const price = yield* Stripe.Price("ProMonthly", {
42 product,
43 currency: "usd",
44 unitAmount: 2000,
45 recurring: { interval: "month" },
46 });
47 yield* Stripe.Coupon("Launch20", {
48 percentOff: 20,
49 duration: "once",
50 name: "Launch 20%",
51 });
52
53 const portalConfig = yield* Stripe.BillingPortalConfiguration("Portal", {
54 name: "Pro customer portal",
55 features: {
56 invoiceHistory: { enabled: true },
57 paymentMethodUpdate: { enabled: true },
58 customerUpdate: { enabled: true, allowedUpdates: ["email"] },
59 subscriptionCancel: { enabled: true, mode: "at_period_end" },
60 },
61 });
62 // ...
63```
64
65`Price` takes the `Product` directly — no id plumbing.
66
67## Carry ids from plan time to runtime
68
69`price.id` is an `Output` — it doesn't have a value until the Price is
70created. Yielding an `Output` inside the Worker's Effect gives you an
71accessor you can yield again from a request handler:
72
73```typescript
74 const priceId = yield* price.id;
75 const portalConfigId = yield* portalConfig.id;
76```
77
78That's the whole mechanism for getting a resource's id into a route.
79There is no environment variable to name and no string to interpolate.
80
81## Bind the Stripe calls the routes make
82
83Three HTTP bindings, one per Stripe call the Worker will make at
84runtime. Each one adds its permission (`customers_write`,
85`checkout_sessions_write`, `billing_portal_write`) to the Worker's
86Stripe token and returns a callable:
87
88```typescript
89 const createCustomer = yield* Stripe.CreateCustomer();
90 const createCheckout = yield* Stripe.CreateCheckoutSession();
91 const createPortal = yield* Stripe.CreateBillingPortalSession();
92```
93
94And the store the webhook handler writes to:
95
96```typescript
97 const entitlements = yield* Cloudflare.KV.Namespace("Entitlements");
98 const kv = yield* Cloudflare.KV.ReadWriteNamespace(entitlements);
99```
100
101## Subscribe to what Stripe tells you
102
103`consumeEvents` provisions the `WebhookEndpoint` pointed at this
104Worker, enables exactly the events you list, and binds the signing
105secret. The handler runs once per verified delivery with a typed
106`event`.
107
108```typescript
109 yield* Stripe.consumeEvents(
110 "Events",
111 {
112 events: [
113 Stripe.CheckoutSessionCompleted,
114 Stripe.CustomerSubscriptionCreated,
115 Stripe.CustomerSubscriptionUpdated,
116 Stripe.CustomerSubscriptionDeleted,
117 Stripe.InvoicePaymentFailed,
118 ],
119 },
120 Effect.fn(function* (event) {
121 const write = (entitlement: Entitlement) =>
122 kv
123 .put(entitlement.customerId, JSON.stringify(entitlement))
124 .pipe(Effect.orDie);
125
126 switch (event.type) {
127 case "checkout.session.completed": {
128 const session = event.object;
129 const customerId = idOf(session.customer);
130 if (customerId === null) return;
131 yield* write({
132 customerId,
133 status: "active",
134 priceId: yield* priceId,
135 subscriptionId: idOf(session.subscription),
136 updatedAt: Date.now(),
137 });
138 return;
139 }
140 case "customer.subscription.created":
141 case "customer.subscription.updated": {
142 const subscription = event.object;
143 yield* write({
144 customerId: idOf(subscription.customer)!,
145 status:
146 subscription.status === "active" ||
147 subscription.status === "trialing"
148 ? "active"
149 : subscription.status === "past_due"
150 ? "past_due"
151 : "canceled",
152 priceId: subscription.items.data[0]?.price.id ?? null,
153 subscriptionId: subscription.id,
154 updatedAt: Date.now(),
155 });
156 return;
157 }
158 case "customer.subscription.deleted": {
159 // ... status: "canceled"
160 }
161 case "invoice.payment_failed": {
162 // ... status: "past_due" — Stripe retries; nudge to the portal
163 }
164 }
165 }),
166 );
167```
168
169The `Entitlement` record is deliberately small — customer, status, which
170price, which subscription. It's the only thing the rest of your app
171needs.
172
173:::note[Which events matter]
174`checkout.session.completed` fires when the buyer pays on the hosted
175page. `customer.subscription.created` follows right behind it (and is
176the only event you'll see if a subscription is created some other way),
177so handle both. `.updated` covers renewals, plan changes, and
178`past_due` → `active` recoveries. `.deleted` is a completed
179cancellation. `invoice.payment_failed` is a declined renewal.
180:::
181
182## The routes
183
184Everything above is plan time. The `fetch` handler is what runs per
185request.
186
187### `POST /checkout` — start a subscription
188
189Create the Customer first so every webhook that follows carries a
190stable `cus_…` id you can key on, then create the Checkout Session
191for the deployed Price and hand back the hosted URL:
192
193```typescript
194 if (request.method === "POST" && url.pathname === "/checkout") {
195 const body = (yield* request.json) as { email?: string };
196 if (!body.email) {
197 return yield* HttpServerResponse.json(
198 { error: "email is required" },
199 { status: 400 },
200 );
201 }
202
203 const customer = yield* createCustomer({ email: body.email }).pipe(
204 Effect.orDie,
205 );
206 const session = yield* createCheckout({
207 mode: "subscription",
208 customer: customer.id,
209 line_items: [{ price: yield* priceId, quantity: 1 }],
210 allow_promotion_codes: true,
211 success_url: `${url.origin}/welcome?session_id={CHECKOUT_SESSION_ID}`,
212 cancel_url: `${url.origin}/pricing`,
213 }).pipe(Effect.orDie);
214
215 return yield* HttpServerResponse.json(
216 { customerId: customer.id, checkoutUrl: session.url },
217 { status: 201 },
218 );
219 }
220```
221
222Your frontend redirects to `checkoutUrl`. Stripe collects the card and
223sends the buyer to `success_url`.
224
225### `POST /portal` — let them manage it
226
227```typescript
228 if (request.method === "POST" && url.pathname === "/portal") {
229 const body = (yield* request.json) as { customerId?: string };
230 // ...
231 const session = yield* createPortal({
232 customer: body.customerId,
233 configuration: yield* portalConfigId,
234 return_url: `${url.origin}/account`,
235 }).pipe(Effect.orDie);
236 return yield* HttpServerResponse.json({ portalUrl: session.url });
237 }
238```
239
240The portal honours the `BillingPortalConfiguration` declared above:
241invoice history, card update, email update, cancel at period end.
242
243### `GET /subscription/:customerId` — what to gate on
244
245```typescript
246 if (
247 request.method === "GET" &&
248 url.pathname.startsWith("/subscription/")
249 ) {
250 const customerId = url.pathname.slice("/subscription/".length);
251 const entitlement = yield* kv
252 .get<Entitlement>(customerId, "json")
253 .pipe(Effect.orDie);
254 return yield* HttpServerResponse.json(
255 entitlement ?? { customerId, status: "none" },
256 );
257 }
258```
259
260This is a KV read. It's what your app calls on every authenticated
261request to decide what to show.
262
263## Provide the layers
264
265The Worker Effect closes by providing every binding's implementation in
266one `Effect.provide([...])`:
267
268```typescript
269 }).pipe(
270 Effect.provide([
271 Cloudflare.KV.ReadWriteNamespaceBinding,
272 Stripe.CreateCustomerHttp,
273 Stripe.CreateCheckoutSessionHttp,
274 Stripe.CreateBillingPortalSessionHttp,
275 Stripe.ConsumeEventsLive,
276 ]),
277 ),
278) {}
279```
280
281## Try it
282
283```sh
284cd examples/stripe-billing
285bun alchemy deploy
286```
287
288```sh
289curl -X POST "$URL/checkout" -H 'content-type: application/json' \
290 -d '{"email":"you@example.com"}'
291# → { "customerId": "cus_…", "checkoutUrl": "https://checkout.stripe.com/…" }
292```
293
294Open `checkoutUrl` in a browser and pay with Stripe's test card
295`4242 4242 4242 4242`. Within a few seconds:
296
297```sh
298curl "$URL/subscription/cus_…"
299# → { "customerId": "cus_…", "status": "active", "priceId": "price_…", … }
300```
301
302Cancel from the portal (`POST /portal` → open `portalUrl`) and the same
303call returns `"status": "canceled"`.
304
305The integration test in `test/integ.test.ts` runs this loop without a
306browser by attaching Stripe's `tok_visa` test card and creating the
307subscription directly, then asserting the webhook-driven entitlement
308reaches `active` and then `canceled`.
309
310## Full file
311
312<details>
313<summary><code>examples/stripe-billing/src/Api.ts</code></summary>
314
315```typescript
316import * as Cloudflare from "alchemy/Cloudflare";
317import * as Stripe from "alchemy/Stripe";
318import * as Effect from "effect/Effect";
319import { HttpServerRequest } from "effect/http/HttpServerRequest";
320import * as HttpServerResponse from "effect/http/HttpServerResponse";
321
322interface Entitlement {
323 customerId: string;
324 status: "active" | "past_due" | "canceled";
325 priceId: string | null;
326 subscriptionId: string | null;
327 updatedAt: number;
328}
329
330export default class Api extends Cloudflare.Worker<Api>()(
331 "Api",
332 {
333 main: import.meta.url,
334 },
335 Effect.gen(function* () {
336 const product = yield* Stripe.Product("Pro", {
337 name: "Pro",
338 description: "Billed monthly",
339 });
340 const price = yield* Stripe.Price("ProMonthly", {
341 product,
342 currency: "usd",
343 unitAmount: 2000,
344 recurring: { interval: "month" },
345 });
346 yield* Stripe.Coupon("Launch20", {
347 percentOff: 20,
348 duration: "once",
349 name: "Launch 20%",
350 });
351
352 const portalConfig = yield* Stripe.BillingPortalConfiguration("Portal", {
353 name: "Pro customer portal",
354 features: {
355 invoiceHistory: { enabled: true },
356 paymentMethodUpdate: { enabled: true },
357 customerUpdate: { enabled: true, allowedUpdates: ["email"] },
358 subscriptionCancel: { enabled: true, mode: "at_period_end" },
359 },
360 });
361
362 const priceId = yield* price.id;
363 const portalConfigId = yield* portalConfig.id;
364
365 const createCustomer = yield* Stripe.CreateCustomer();
366 const createCheckout = yield* Stripe.CreateCheckoutSession();
367 const createPortal = yield* Stripe.CreateBillingPortalSession();
368
369 const entitlements = yield* Cloudflare.KV.Namespace("Entitlements");
370 const kv = yield* Cloudflare.KV.ReadWriteNamespace(entitlements);
371
372 yield* Stripe.consumeEvents(
373 "Events",
374 {
375 events: [
376 Stripe.CheckoutSessionCompleted,
377 Stripe.CustomerSubscriptionCreated,
378 Stripe.CustomerSubscriptionUpdated,
379 Stripe.CustomerSubscriptionDeleted,
380 Stripe.InvoicePaymentFailed,
381 ],
382 },
383 Effect.fn(function* (event) {
384 const write = (entitlement: Entitlement) =>
385 kv
386 .put(entitlement.customerId, JSON.stringify(entitlement))
387 .pipe(Effect.orDie);
388
389 switch (event.type) {
390 case "checkout.session.completed": {
391 const session = event.object;
392 const customerId = idOf(session.customer);
393 if (customerId === null) return;
394 yield* write({
395 customerId,
396 status: "active",
397 priceId: yield* priceId,
398 subscriptionId: idOf(session.subscription),
399 updatedAt: Date.now(),
400 });
401 return;
402 }
403 case "customer.subscription.created":
404 case "customer.subscription.updated": {
405 const subscription = event.object;
406 yield* write({
407 customerId: idOf(subscription.customer)!,
408 status:
409 subscription.status === "active" ||
410 subscription.status === "trialing"
411 ? "active"
412 : subscription.status === "past_due"
413 ? "past_due"
414 : "canceled",
415 priceId: subscription.items.data[0]?.price.id ?? null,
416 subscriptionId: subscription.id,
417 updatedAt: Date.now(),
418 });
419 return;
420 }
421 case "customer.subscription.deleted": {
422 const subscription = event.object;
423 yield* write({
424 customerId: idOf(subscription.customer)!,
425 status: "canceled",
426 priceId: null,
427 subscriptionId: subscription.id,
428 updatedAt: Date.now(),
429 });
430 return;
431 }
432 case "invoice.payment_failed": {
433 const invoice = event.object;
434 const customerId = idOf(invoice.customer);
435 if (customerId === null) return;
436 const current = yield* kv
437 .get<Entitlement>(customerId, "json")
438 .pipe(Effect.orDie);
439 yield* write({
440 customerId,
441 status: "past_due",
442 priceId: current?.priceId ?? null,
443 subscriptionId: current?.subscriptionId ?? null,
444 updatedAt: Date.now(),
445 });
446 return;
447 }
448 }
449 }),
450 );
451
452 return {
453 fetch: Effect.gen(function* () {
454 const request = yield* HttpServerRequest;
455 const url = new URL(request.originalUrl);
456
457 if (request.method === "POST" && url.pathname === "/checkout") {
458 const body = (yield* request.json) as { email?: string };
459 if (!body.email) {
460 return yield* HttpServerResponse.json(
461 { error: "email is required" },
462 { status: 400 },
463 );
464 }
465
466 const customer = yield* createCustomer({ email: body.email }).pipe(
467 Effect.orDie,
468 );
469 const session = yield* createCheckout({
470 mode: "subscription",
471 customer: customer.id,
472 line_items: [{ price: yield* priceId, quantity: 1 }],
473 allow_promotion_codes: true,
474 success_url: `${url.origin}/welcome?session_id={CHECKOUT_SESSION_ID}`,
475 cancel_url: `${url.origin}/pricing`,
476 }).pipe(Effect.orDie);
477
478 return yield* HttpServerResponse.json(
479 { customerId: customer.id, checkoutUrl: session.url },
480 { status: 201 },
481 );
482 }
483
484 if (request.method === "POST" && url.pathname === "/portal") {
485 const body = (yield* request.json) as { customerId?: string };
486 if (!body.customerId) {
487 return yield* HttpServerResponse.json(
488 { error: "customerId is required" },
489 { status: 400 },
490 );
491 }
492 const session = yield* createPortal({
493 customer: body.customerId,
494 configuration: yield* portalConfigId,
495 return_url: `${url.origin}/account`,
496 }).pipe(Effect.orDie);
497 return yield* HttpServerResponse.json({ portalUrl: session.url });
498 }
499
500 if (
501 request.method === "GET" &&
502 url.pathname.startsWith("/subscription/")
503 ) {
504 const customerId = url.pathname.slice("/subscription/".length);
505 const entitlement = yield* kv
506 .get<Entitlement>(customerId, "json")
507 .pipe(Effect.orDie);
508 return yield* HttpServerResponse.json(
509 entitlement ?? { customerId, status: "none" },
510 );
511 }
512
513 if (url.pathname === "/welcome" || url.pathname === "/pricing") {
514 return HttpServerResponse.text(
515 url.pathname === "/welcome"
516 ? "Thanks — your subscription is active."
517 : "Pricing page.",
518 );
519 }
520
521 return yield* HttpServerResponse.json(
522 { error: "Not found" },
523 { status: 404 },
524 );
525 }),
526 };
527 }).pipe(
528 Effect.provide([
529 Cloudflare.KV.ReadWriteNamespaceBinding,
530 Stripe.CreateCustomerHttp,
531 Stripe.CreateCheckoutSessionHttp,
532 Stripe.CreateBillingPortalSessionHttp,
533 Stripe.ConsumeEventsLive,
534 ]),
535 ),
536) {}
537
538const idOf = (
539 ref: string | { id: string } | { id?: string } | null | undefined,
540): string | null =>
541 ref == null ? null : typeof ref === "string" ? ref : (ref.id ?? null);
542```
543
544</details>
545
546## Next
547
548- [Connect](/stripe/guides/connect) — onboard other businesses and pay
549 them out.
550- [BillingPortalConfiguration](/providers/stripe/billingportalconfiguration),
551 [Price](/providers/stripe/price),
552 [WebhookEndpoint](/providers/stripe/webhookendpoint) reference.