◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Claude Code
⟐ Claude Code/2026-04-20Advanced

Building a Stripe Subscription SaaS with Claude Code: Webhooks, Auth, and Production Pitfalls

Shipping a Stripe subscription SaaS with Claude Code on Cloudflare Workers: Webhook verification, KV + Cookie auth, and a cookie regex that failed silently.

Claude Code264Stripe15SaaS15Cloudflare Workers16Webhook3subscription5payments

✦ Premium Article

When the first successful payment notification appeared in the Stripe Dashboard, I said something out loud. After two weeks of wrestling with Claude Code to build a subscription system, it was finally working in production.

What I built was a SaaS billing system that handles monthly subscriptions and lifetime licenses simultaneously — plus per-article purchases for individual premium content. Getting the full flow working in a Cloudflare Workers environment — Stripe Checkout → Webhook → Cloudflare KV → Cookie → content access — took considerably more debugging time than I anticipated.

This article documents everything I learned. That includes the pitfalls official documentation doesn't warn you about, and the process of designing the system alongside Claude Code. I've tried to be honest about where things went wrong and why.

Design the Full Payment Flow Before Writing Any Code

The most common mistake in Stripe implementations is "let's get Checkout working first, then figure out the rest." I made exactly this mistake.

The result was Webhook handling and access control logic bolted on afterward, creating entangled code. When I asked Claude Code to refactor it, the response was essentially: "The design itself needs rethinking — refactoring won't fix the structural problem." That assessment was accurate, and heeding it saved me from a more painful rewrite later.

Before writing any implementation code, map out this six-step flow and keep it somewhere visible while you work:

  • Purchase button on the frontend → POST to /api/checkout
  • /api/checkout → Create Stripe Checkout session → Redirect user to Stripe-hosted page
  • User completes payment → Stripe fires checkout.session.completed Webhook
  • Webhook handler → Write access grant to Cloudflare KV with appropriate TTL
  • Post-payment redirect → /api/verify-session → Validate session and issue Cookie
  • Cookie → Content access control on every protected page load

Having this flow explicit before coding keeps you oriented when you're deep in implementation details. It also makes dependency ordering obvious: you can't build verify-session before you know what the Cookie format needs to contain, and you can't design the Cookie format before you know what the KV schema looks like.

Using Claude Code for Architecture Review

Before touching any implementation, I described the full flow to Claude Code and asked: "What are the risks with this design?" It surfaced two issues immediately — idempotency (what happens when Webhook events are delivered more than once) and a race condition possibility if verify-session runs before the Webhook has written to KV.

For the race condition, the solution was simple: verify-session reads from Stripe directly (using the session_id parameter) rather than relying on KV having been written yet. The Webhook and the Cookie issuance are now independent flows, not dependent on each other's completion order.

Payment systems are hard to change once they're running. Investing in design review before implementation pays dividends far beyond what it costs in time.

Centralizing Everything in pricing.ts

Every price ID, display price, and UI copy for payment flows should live in a single configuration file. If you hardcode these across components, a price change means hunting through your entire codebase. For a system with multiple currencies and plan types, this discipline pays off almost immediately.

// src/config/pricing.ts
 
export const STRIPE_PRICE_IDS = {
  ja: {
    tip: "price_xxxxxxxxxxxx",     // ¥150
    article: "price_yyyyyyyyyyyy", // ¥250 (per-article)
    pro: "price_zzzzzzzzzzzz",     // ¥580/month
    premium: "price_wwwwwwwwwwww", // ¥2,480 (lifetime)
  },
  en: {
    tip: "price_aaaaaaaaaaaa",
    article: "price_bbbbbbbbbbbb",
    pro: "price_cccccccccccc",
    premium: "price_dddddddddddd",
  },
} as const;
 
// Works around the as const indexing limitation
export const getPriceIds = (locale: string) => {
  const key = locale === "ja" ? "ja" : "en";
  return STRIPE_PRICE_IDS[key];
};
 
// Co-locate CTA text with pricing — both change together
export const ARTICLE_LABELS = {
  ja: {
    heading: "この記事の全文を読む",
    description: "詳細な実装コードと解説の続きをご覧ください",
    button: "¥250 で購入",
    separator: "または",
    membershipText: "月額メンバーシップで全記事を読み放題",
  },
  en: {
    heading: "Read the Full Article",
    description: "Access the complete implementation code and detailed explanations",
    button: "Buy for $1.75",
    separator: "or",
    membershipText: "Get unlimited access with a monthly membership",
  },
};
 
// Helper for unit amounts (Stripe uses smallest currency unit)
export const getUnitAmount = (planType: string, locale: string): number => {
  const amounts: Record<string, Record<string, number>> = {
    ja: { tip: 150, article: 250, pro: 580, premium: 2480 },
    en: { tip: 150, article: 175, pro: 500, premium: 1500 }, // in cents
  };
  return amounts[locale === "ja" ? "ja" : "en"][planType] ?? 0;
};

The as const Indexing Trap

Defining STRIPE_PRICE_IDS as const makes TypeScript infer the most specific type possible, which is exactly what you want for type safety. But it means you can't index it with a plain string variable.

// ❌ TypeScript error — string can't index an as const type
const priceId = STRIPE_PRICE_IDS[locale].premium;
 
// ✅ Use the helper function (handles the type narrowing internally)
const priceId = getPriceIds(locale).premium;
 
// ✅ For server components, a typed intermediate constant also works
const PRICE_IDS: Record<string, { tip: string; article: string; pro: string; premium: string }> = STRIPE_PRICE_IDS;
const priceId = PRICE_IDS[locale]?.premium;

When I described this problem to Claude Code ("I have an as const object and TypeScript won't let me index it with a string-typed variable"), it identified the pattern immediately and explained both approaches — which to use in a server component versus a client component, and why.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦How to wire the full Checkout → Webhook → KV → Cookie billing flow on Cloudflare Workers, step by step
✦Production-only pitfalls — signature verification, timeouts, duplicate delivery, and event reordering — each with concrete fix code
✦A two-layer KV + Cookie access-control pattern that preserves deny-by-default — plus four measured cases showing how the parseCookie regex in front of it silently returned null
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⟐ Claude Code2026-05-03
Claude × Stripe Meter Events — Building a Usage-Based SaaS
A production-ready architecture and full implementation for billing Claude API token consumption through Stripe Meter Events — covering idempotency, usage caps, and a hybrid base + overage pricing model.
⟐ Claude Code2026-08-19
Fixing the Code Doesn't Evict a Broken Page From the Edge Cache
I shipped a fix and the broken page kept serving. The problem was not the code but the edge cache, which had stored a broken HTML response because the store decision looked only at the status code. Here is the integrity guard I now run before every cache write, and the thresholds I had to tune in production.
⟐ Claude Code2026-04-15
Building StoreKit 2 × SwiftUI Purchases with Claude Code — The Minimum That Passes Review
Build a StoreKit 2 × SwiftUI purchase flow end to end with Claude Code as your design partner — PurchaseManager, server-side validation, and paywall UI — plus the operational traps Apple's docs skip, like Ask to Buy and late-arriving transactions.