| 1 | --- |
| 2 | title: Sell a subscription |
| 3 | description: 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 | |
| 6 | You have a product with a monthly price and you want people to pay for |
| 7 | it. The shape every SaaS lands on is: |
| 8 | |
| 9 | 1. **Checkout** — your app creates a Stripe Customer and a hosted |
| 10 | Checkout Session, then redirects the buyer to Stripe's payment page. |
| 11 | 2. **Webhooks** — Stripe tells you what happened (`paid`, `renewed`, |
| 12 | `card declined`, `canceled`). Your app writes that to its own store. |
| 13 | 3. **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. |
| 16 | 4. **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 | |
| 20 | This guide walks through `examples/stripe-billing`, which does exactly |
| 21 | that 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); |
| 23 | the whole file is at the end. |
| 24 | |
| 25 | ## The catalog lives on the Worker |
| 26 | |
| 27 | The Product, its Price, a launch Coupon, and the Billing Portal |
| 28 | configuration are Stack resources declared *inside* the Worker's |
| 29 | Effect. They deploy with the Worker and are destroyed with it. |
| 30 | |
| 31 | ```typescript |
| 32 | // src/Api.ts |
| 33 | export 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 |
| 70 | created. Yielding an `Output` inside the Worker's Effect gives you an |
| 71 | accessor 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 | |
| 78 | That's the whole mechanism for getting a resource's id into a route. |
| 79 | There is no environment variable to name and no string to interpolate. |
| 80 | |
| 81 | ## Bind the Stripe calls the routes make |
| 82 | |
| 83 | Three HTTP bindings, one per Stripe call the Worker will make at |
| 84 | runtime. Each one adds its permission (`customers_write`, |
| 85 | `checkout_sessions_write`, `billing_portal_write`) to the Worker's |
| 86 | Stripe 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 | |
| 94 | And 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 |
| 104 | Worker, enables exactly the events you list, and binds the signing |
| 105 | secret. 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 | |
| 169 | The `Entitlement` record is deliberately small — customer, status, which |
| 170 | price, which subscription. It's the only thing the rest of your app |
| 171 | needs. |
| 172 | |
| 173 | :::note[Which events matter] |
| 174 | `checkout.session.completed` fires when the buyer pays on the hosted |
| 175 | page. `customer.subscription.created` follows right behind it (and is |
| 176 | the only event you'll see if a subscription is created some other way), |
| 177 | so handle both. `.updated` covers renewals, plan changes, and |
| 178 | `past_due` → `active` recoveries. `.deleted` is a completed |
| 179 | cancellation. `invoice.payment_failed` is a declined renewal. |
| 180 | ::: |
| 181 | |
| 182 | ## The routes |
| 183 | |
| 184 | Everything above is plan time. The `fetch` handler is what runs per |
| 185 | request. |
| 186 | |
| 187 | ### `POST /checkout` — start a subscription |
| 188 | |
| 189 | Create the Customer first so every webhook that follows carries a |
| 190 | stable `cus_…` id you can key on, then create the Checkout Session |
| 191 | for 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 | |
| 222 | Your frontend redirects to `checkoutUrl`. Stripe collects the card and |
| 223 | sends 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 | |
| 240 | The portal honours the `BillingPortalConfiguration` declared above: |
| 241 | invoice 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 | |
| 260 | This is a KV read. It's what your app calls on every authenticated |
| 261 | request to decide what to show. |
| 262 | |
| 263 | ## Provide the layers |
| 264 | |
| 265 | The Worker Effect closes by providing every binding's implementation in |
| 266 | one `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 |
| 284 | cd examples/stripe-billing |
| 285 | bun alchemy deploy |
| 286 | ``` |
| 287 | |
| 288 | ```sh |
| 289 | curl -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 | |
| 294 | Open `checkoutUrl` in a browser and pay with Stripe's test card |
| 295 | `4242 4242 4242 4242`. Within a few seconds: |
| 296 | |
| 297 | ```sh |
| 298 | curl "$URL/subscription/cus_…" |
| 299 | # → { "customerId": "cus_…", "status": "active", "priceId": "price_…", … } |
| 300 | ``` |
| 301 | |
| 302 | Cancel from the portal (`POST /portal` → open `portalUrl`) and the same |
| 303 | call returns `"status": "canceled"`. |
| 304 | |
| 305 | The integration test in `test/integ.test.ts` runs this loop without a |
| 306 | browser by attaching Stripe's `tok_visa` test card and creating the |
| 307 | subscription directly, then asserting the webhook-driven entitlement |
| 308 | reaches `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 |
| 316 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 317 | import * as Stripe from "alchemy/Stripe"; |
| 318 | import * as Effect from "effect/Effect"; |
| 319 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 320 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 321 | |
| 322 | interface Entitlement { |
| 323 | customerId: string; |
| 324 | status: "active" | "past_due" | "canceled"; |
| 325 | priceId: string | null; |
| 326 | subscriptionId: string | null; |
| 327 | updatedAt: number; |
| 328 | } |
| 329 | |
| 330 | export 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 | |
| 538 | const 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. |