Tutorials Ultimate Web Development Series › Chapter 13

Stripe Checkout + Webhooks — A Real Subscription Backend

WebChapter 13 of the Ultimate Web Development Series40 minApril 20, 2026Intermediate

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

Loading diagram…

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:

  1. Your Worker — creates a Checkout Session, builds a URL, redirects the user.
  2. Stripe Checkout — hosts the payment page. Collects card, address, VAT. Reports success/failure back.
  3. 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

  1. Sign up at stripe.com. Test mode is free forever.
  2. Go to Products → Add product. Name: "Pro". Price: $7.99 USD, recurring monthly. Save.
  3. Copy the Price ID (starts with price_…). You'll reference this when creating Checkout Sessions.
  4. 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:

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:

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

Loading diagram…

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:

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:

  1. Stripe account + Pro product + Price. Keys to wrangler secret put.
  2. Schema migration for Stripe columns + events table.
  3. /api/billing/checkout endpoint.
  4. /api/billing/webhook endpoint with signature verification + dedup.
  5. /api/billing/portal endpoint.
  6. Tier check on one endpoint (e.g., a pro-only export).
  7. Frontend: a "Subscribe" button → fetch('/api/billing/checkout')window.location = url.
  8. Use stripe listen locally to test the webhook. Use test card 4242 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.comsaas/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:

  1. 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.
  2. Read the next chapter — Ch 14: Project Study — Dissecting saas/src/index.js, and see every pattern you just built in a production backend.
Ch 12: Google OAuth 2.0 Step-by-StepCh 14: Project Study — Dissecting saas/src/index.js

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
5 free articles remainingSubscribe for unlimited access