Your users can sign up and sign in. Now they need to pay you. This is the chapter where your hobby project becomes a business.
Stripe is the payments standard for SaaS. It handles the forms, the card data (so you never see it), the international methods, the failed-card retries, the tax, the receipts. Your Worker's job shrinks to three things: send users to Stripe, receive webhooks, and gate features based on the subscription state. This chapter walks all three.
And yes — because we're on the web, not the App Store — you keep 97% instead of 70%. That's the single biggest reason most indie SaaS runs on Stripe directly instead of through Apple IAP.
The Three Pieces of a Stripe Subscription
Figure 1 — The three-piece flow. The user touches Stripe's hosted Checkout page (you never see their card). Stripe's webhook is the authoritative "what subscription state does this user have right now."
Three actors, three responsibilities:
- Your Worker — creates a Checkout Session, builds a URL, redirects the user.
- Stripe Checkout — hosts the payment page. Collects card, address, VAT. Reports success/failure back.
- Webhooks — Stripe POSTs events to your Worker when anything changes (subscription created, payment failed, subscription cancelled). This is your source of truth.
The rule that saves you from every race condition: never trust the browser's "success" redirect. Update subscription state only when the signed webhook arrives. The redirect is UX; the webhook is truth.
Step 1 — Create a Stripe Account + Product
- Sign up at stripe.com. Test mode is free forever.
- Go to Products → Add product. Name: "Pro". Price:
$7.99 USD, recurring monthly. Save. - Copy the Price ID (starts with
price_…). You'll reference this when creating Checkout Sessions. - Grab your test keys from Developers → API keys:
pk_test_…— publishable (safe to ship to browsers, but we won't use it — Stripe Checkout doesn't need one).sk_test_…— secret. Keep in Worker secrets only.
Store them:
wrangler secret put STRIPE_SECRET_KEY # sk_test_...
wrangler secret put STRIPE_PRICE_ID # price_...
wrangler secret put STRIPE_WEBHOOK_SECRET # we'll get this below
Step 2 — Create a Checkout Session
Your Worker talks to Stripe's REST API with fetch. No SDK needed — the endpoint is well-documented and takes form-encoded parameters.
// POST /api/billing/checkout
if (pathname === "/api/billing/checkout" && method === "POST") {
const user = await authed(request, env);
if (!user) return cors(json({ error: "unauthorized" }, 401));
// If this user already has a Stripe customer, reuse it. Otherwise Stripe will create one.
const row = await env.DB
.prepare("SELECT stripe_customer_id FROM users WHERE id = ?")
.bind(user.id)
.first();
const body = new URLSearchParams({
mode: "subscription",
"line_items[0][price]": env.STRIPE_PRICE_ID,
"line_items[0][quantity]": "1",
success_url: `${env.WEB_ORIGIN}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.WEB_ORIGIN}/pricing`,
// Tie this Checkout Session back to your user
client_reference_id: user.id,
// If we already have a customer, reuse; otherwise Stripe creates one using this email
...(row?.stripe_customer_id
? { customer: row.stripe_customer_id }
: { customer_email: user.email }),
allow_promotion_codes: "true",
});
const res = await fetch("https://api.stripe.com/v1/checkout/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!res.ok) {
console.error("Stripe checkout error:", await res.text());
return cors(json({ error: "stripe_error" }, 500));
}
const session = await res.json();
return cors(json({ url: session.url }));
}
Read that top to bottom:
mode: "subscription"— recurring billing, not one-time.line_items[0][price]— references the Price you created in the dashboard.success_url/cancel_url— where Stripe bounces the user after paying or cancelling.client_reference_id— your internal user id, echoed back in webhooks. This is how you know which user in your DB just subscribed.customer_email— prefills the checkout form. If you already know the user's Stripe customer, passcustomerinstead to keep one Stripe customer per user forever.
The response has a session.url — redirect the user there:
// Frontend
const res = await fetch("/api/billing/checkout", { method: "POST" });
const { url } = await res.json();
window.location = url; // off to Stripe
On Stripe's page the user enters card details, pays, and gets redirected to your success_url. At the same time — and this is the important part — Stripe fires a webhook at your server.
Step 3 — Receiving Webhooks
A webhook is Stripe making an HTTP POST to your endpoint to tell you an event happened. Every subscription change, successful payment, failed payment, refund, dispute — all webhooks. This is the only authoritative source of state; don't trust client redirects.
Verify the signature
Stripe signs every webhook with HMAC so you can prove it came from them. Without this check, anyone could POST fake events to your endpoint and upgrade themselves to Pro for free.
// Full HMAC verification in 20 lines — same pattern as the JWT in Ch 11
async function verifyStripeSignature(rawBody, sigHeader, secret) {
// sigHeader looks like "t=1700000000,v1=abc123,v0=xyz..."
const parts = Object.fromEntries(
sigHeader.split(",").map(kv => kv.split("=", 2))
);
const timestamp = parts.t;
const expected = parts.v1;
if (!timestamp || !expected) return false;
// Age check: reject events older than 5 minutes (replay protection)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const signedPayload = `${timestamp}.${rawBody}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signedPayload));
const actual = Array.from(new Uint8Array(mac))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
return actual === expected;
}
The webhook handler
// POST /api/billing/webhook
if (pathname === "/api/billing/webhook" && method === "POST") {
const rawBody = await request.text(); // MUST read as raw text — JSON.parse breaks HMAC
const sig = request.headers.get("Stripe-Signature") ?? "";
const ok = await verifyStripeSignature(rawBody, sig, env.STRIPE_WEBHOOK_SECRET);
if (!ok) return new Response("Invalid signature", { status: 400 });
const event = JSON.parse(rawBody);
// Idempotency: record processed event IDs so retries don't double-apply
const alreadyProcessed = await env.DB
.prepare("SELECT event_id FROM stripe_events WHERE event_id = ?")
.bind(event.id)
.first();
if (alreadyProcessed) return new Response("ok", { status: 200 });
try {
await handleStripeEvent(event, env);
await env.DB
.prepare("INSERT INTO stripe_events (event_id, event_type) VALUES (?, ?)")
.bind(event.id, event.type)
.run();
return new Response("ok", { status: 200 });
} catch (err) {
console.error("Webhook handling failed:", err);
// Return 500 so Stripe retries
return new Response("error", { status: 500 });
}
}
async function handleStripeEvent(event, env) {
const obj = event.data.object;
switch (event.type) {
case "checkout.session.completed": {
const userId = obj.client_reference_id;
await env.DB
.prepare(`
UPDATE users
SET stripe_customer_id = ?, stripe_subscription_id = ?, subscription_tier = 'pro', subscription_expires_at = NULL
WHERE id = ?
`)
.bind(obj.customer, obj.subscription, userId)
.run();
break;
}
case "customer.subscription.updated": {
// Stripe tells us the new status (active, past_due, canceled, etc.)
const status = obj.status;
const endsAt = obj.cancel_at ? new Date(obj.cancel_at * 1000).toISOString() : null;
const tier = status === "active" || status === "trialing" ? "pro" : "free";
await env.DB
.prepare(`
UPDATE users
SET subscription_tier = ?, subscription_expires_at = ?
WHERE stripe_subscription_id = ?
`)
.bind(tier, endsAt, obj.id)
.run();
break;
}
case "customer.subscription.deleted": {
await env.DB
.prepare("UPDATE users SET subscription_tier = 'free', subscription_expires_at = NULL WHERE stripe_subscription_id = ?")
.bind(obj.id)
.run();
break;
}
default:
// Ignore everything else for this app
console.log("Unhandled event type:", event.type);
}
}
Three lines worth highlighting:
await request.text()— not.json(). The signature is computed over the exact bytes Stripe sent. If youJSON.parsefirst and re-stringify, the bytes differ (whitespace, field order) and verification fails. Read raw.- Idempotency table. Stripe will retry a webhook if your server returns anything other than 2xx. Without the dedup table, a retry would double-apply the event. One column
event_id PRIMARY KEYis all you need. - Return 500 on unexpected errors. Stripe retries with exponential backoff for a couple of days. That's a safety net for transient DB failures.
Get the webhook secret
In the Stripe dashboard: Developers → Webhooks → Add endpoint. URL: https://your-api.yourname.workers.dev/api/billing/webhook. Select events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted. After creating, click into the endpoint — you'll see a "Signing secret" starting with whsec_.... Copy that to wrangler secret put STRIPE_WEBHOOK_SECRET.
For local testing, use the Stripe CLI:
stripe listen --forward-to http://localhost:8787/api/billing/webhook
It creates a temporary webhook that forwards events to your local Worker + prints a temporary signing secret you put in your .dev.vars.
Step 4 — Gate Features by Tier
Now your users row has subscription_tier. Use it in protected endpoints:
// Example: a Pro-only endpoint
if (pathname === "/api/notes/export" && method === "GET") {
const user = await authed(request, env);
if (!user) return cors(json({ error: "unauthorized" }, 401));
const tier = await env.DB
.prepare("SELECT subscription_tier FROM users WHERE id = ?")
.bind(user.id)
.first()
.then(r => r?.subscription_tier);
if (tier !== "pro") {
return cors(json({
error: "payment_required",
message: "This feature is for Pro subscribers. Upgrade at /pricing.",
}, 402)); // 402 Payment Required is a real status code!
}
// ... export logic ...
}
One extra status code: 402 Payment Required. Designed for exactly this — "your credentials are valid but you need to pay to use this endpoint."
Step 5 — Customer Portal
When a user wants to update their card, view invoices, or cancel — Stripe hosts that too:
// POST /api/billing/portal
if (pathname === "/api/billing/portal" && method === "POST") {
const user = await authed(request, env);
if (!user) return cors(json({ error: "unauthorized" }, 401));
const row = await env.DB
.prepare("SELECT stripe_customer_id FROM users WHERE id = ?")
.bind(user.id)
.first();
if (!row?.stripe_customer_id) return cors(json({ error: "no_customer" }, 400));
const res = await fetch("https://api.stripe.com/v1/billing_portal/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${env.STRIPE_SECRET_KEY}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
customer: row.stripe_customer_id,
return_url: env.WEB_ORIGIN + "/account",
}),
});
const { url } = await res.json();
return cors(json({ url }));
}
Redirect the user to url. They'll see a Stripe-hosted page where they can do everything billing-related. When they cancel, your webhook fires and your DB updates. Zero custom forms.
Subscription Lifecycle — States You'll See
Figure 2 — The real states of a Stripe subscription. past_due is the one most new builders miss — the user's card failed, Stripe is retrying, and you have a business decision to make: lock them out immediately, or give grace period days while retries run?
Schema — Add Stripe Columns
-- migrations/0005_stripe.sql
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT;
ALTER TABLE users ADD COLUMN subscription_tier TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN subscription_expires_at TEXT;
CREATE TABLE IF NOT EXISTS stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_users_stripe_customer ON users(stripe_customer_id);
CREATE INDEX IF NOT EXISTS idx_users_stripe_subscription ON users(stripe_subscription_id);
Why Stripe Beats Apple IAP for Indie SaaS
For apps that operate on the web (or native with a web-opened checkout), Stripe is a decisive win over Apple's in-app purchases:
- Fees: Stripe 2.9% + $0.30. Apple 30% (or 15% small-business program). On a $7.99/mo sub: Stripe keeps $7.46, Apple keeps $5.59.
- Refunds are instant through Stripe; Apple's are opaque.
- Subscription management through the Customer Portal is user-friendly; Apple's is buried.
- Testing is trivial (test card
4242 4242 4242 4242); Apple's sandbox is legendarily flaky. - Tax is automatic through Stripe Tax; Apple handles it for you, but opaquely.
- Analytics in Stripe are real-time; App Store Connect lags 24+ hours.
The one case Stripe doesn't win: if your app ships on the App Store, Apple requires IAP for digital content consumed inside the app. For hybrid apps (most indie SaaS), sell the subscription on your web site with Stripe; use the iOS app to access what they already paid for. That's the pattern simpleappshipper.com and its Mac app use.
Exercise — Ship Pro Tier
Take the Ch 12 Worker and add:
- Stripe account + Pro product + Price. Keys to
wrangler secret put. - Schema migration for Stripe columns + events table.
/api/billing/checkoutendpoint./api/billing/webhookendpoint with signature verification + dedup./api/billing/portalendpoint.- Tier check on one endpoint (e.g., a pro-only export).
- Frontend: a "Subscribe" button →
fetch('/api/billing/checkout')→window.location = url. - Use
stripe listenlocally to test the webhook. Use test card4242 4242 4242 4242, any future date, any CVC.
You just shipped a subscription SaaS. The Mac App Store is no longer a prerequisite for making money from software.
Next Steps
One chapter left in Part 2. In Ch 14 we open the actual Worker source behind simpleappshipper.com — saas/src/index.js, ~1800 lines of production code — and walk through the exact JWT + OAuth + Stripe + D1 + R2 plumbing you just built. Every pattern in Chs 8-13, in real shipped code handling real money.
Next:
- Keep your Stripe test data for a while. Switch the dashboard to Live mode only when ready to charge real cards; rotate to live API keys + webhook signing secret at that point.
- Read the next chapter — Ch 14: Project Study — Dissecting saas/src/index.js, and see every pattern you just built in a production backend.
Ship your apps faster
When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.
Try Simple App Shipper