Skip to content

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:

  1. Checkout — your app creates a Stripe Customer and a hosted Checkout Session, then redirects the buyer to Stripe’s payment page.
  2. Webhooks — Stripe tells you what happened (paid, renewed, card declined, canceled). Your app writes that to its own store.
  3. Entitlement — the rest of your app reads that store to decide whether to serve paid features. It never calls Stripe on the request path.
  4. 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 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.

src/Api.ts
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.

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.

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);

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.

Everything above is plan time. The fetch handler is what runs per request.

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.

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.

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,
]),
),
) {}
Terminal window
cd examples/stripe-billing
bun alchemy deploy
Terminal window
curl -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:

Terminal window
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.

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);