●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Building a Stripe Subscription SaaS with Claude Code: Webhooks, Auth, and Production Pitfalls
A complete implementation guide for building a Stripe subscription SaaS with Claude Code on Cloudflare Workers. Covers Webhook signature verification, two-layer KV + Cookie auth, and the production pitfalls that official docs won't warn you about.
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.tsexport 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 limitationexport const getPriceIds = (locale: string) => { const key = locale === "ja" ? "ja" : "en"; return STRIPE_PRICE_IDS[key];};// Co-locate CTA text with pricing — both change togetherexport 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 typeconst 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 worksconst 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 during outages
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.
Implementing the Checkout API — The product_data Requirement
The first surprise most developers hit: passing only a price ID to Stripe Checkout means your product description and images won't appear on the payment page. The price_data + product_data approach is required to control the Checkout presentation.
// src/app/api/checkout/route.tsimport Stripe from "stripe";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, { apiVersion: "2024-11-20.acacia",});export async function POST(request: Request) { const { locale, planType, articleSlug } = await request.json(); // plan_type in metadata is mandatory // verify-session uses it to branch between tip/article/pro/premium behavior // Omitting this causes tip purchases to accidentally grant Premium access const metadata: Record<string, string> = { plan_type: planType, locale, }; if (articleSlug) { metadata.article_slug = articleSlug; } const isSubscription = planType === "pro"; const session = await stripe.checkout.sessions.create({ payment_method_types: ["card"], line_items: [ { price_data: { currency: locale === "ja" ? "jpy" : "usd", unit_amount: getUnitAmount(planType, locale), product_data: { name: getProductName(planType, locale), description: getProductDescription(planType, locale), // Without images, descriptions are also less prominent — undocumented Stripe behavior images: [`${process.env.NEXT_PUBLIC_SITE_URL}/images/stripe-product.png`], }, ...(isSubscription && { recurring: { interval: "month" } }), }, quantity: 1, }, ], mode: isSubscription ? "subscription" : "payment", success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/${locale}/api/verify-session?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/${locale}/membership`, metadata, }); return Response.json({ url: session.url });}
The plan_type Bug That's Easy to Ship
Without explicit plan_type metadata, verify-session has to guess what kind of purchase just happened. If you guess based on the amount (e.g., "¥150 must be a tip"), that breaks immediately the first time you run a promotion. If you guess based on whether it's a subscription or one-time payment, you can't distinguish lifetime purchases from per-article purchases.
The concrete failure mode: a tip purchase accidentally grants Pro membership because verify-session can't tell them apart.
Locale Separation for International Pricing
If you're running separate Japanese and English pricing, keep them as separate Stripe Price objects with separate price IDs. Don't try to handle locale at the Checkout session level by overriding currency — the Checkout page rendering, receipt formatting, and Stripe reporting all work more cleanly when each locale has its own dedicated prices.
Webhook Implementation — The Cloudflare Workers Difference
This is where most Workers-based Stripe implementations fail. The standard stripe.webhooks.constructEvent() is synchronous and uses Node's crypto module internally. Cloudflare Workers uses the Web Crypto API (SubtleCrypto), which is async-only.
// src/app/api/webhook/route.tsimport Stripe from "stripe";import { getCloudflareContext } from "@opennextjs/cloudflare";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, { apiVersion: "2024-11-20.acacia",});export async function POST(request: Request) { const body = await request.text(); const signature = request.headers.get("stripe-signature")\!; let event: Stripe.Event; try { // constructEvent (sync) fails silently or throws in Workers // constructEventAsync + createSubtleCryptoProvider is the correct approach event = await stripe.webhooks.constructEventAsync( body, signature, process.env.STRIPE_WEBHOOK_SECRET\!, undefined, Stripe.createSubtleCryptoProvider() ); } catch (err) { console.error("Webhook signature verification failed:", err); return new Response("Webhook Error", { status: 400 }); } switch (event.type) { case "checkout.session.completed": await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session); break; case "customer.subscription.updated": await handleSubscriptionUpdate(event.data.object as Stripe.Subscription); break; case "customer.subscription.deleted": await handleSubscriptionDelete(event.data.object as Stripe.Subscription); break; default: // Always return 200 for unhandled event types // Returning 4xx causes Stripe to retry, flooding your logs break; } return new Response("OK", { status: 200 });}async function handleCheckoutComplete(session: Stripe.Checkout.Session) { const { plan_type } = session.metadata ?? {}; const email = session.customer_details?.email; if (\!email) { console.error("checkout.session.completed: no customer email"); return; } const { env } = await getCloudflareContext(); switch (plan_type) { case "premium": // Lifetime access — TTL of 100 years is effectively permanent await env.KV.put( `site:claudelab:premium:${email}`, JSON.stringify({ plan: "premium", grantedAt: Date.now() }), { expirationTtl: 60 * 60 * 24 * 365 * 100 } ); break; case "pro": const periodEnd = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 31; await env.KV.put( `site:claudelab:pro:${email}`, JSON.stringify({ plan: "pro", periodEnd }), { expirationTtl: periodEnd - Math.floor(Date.now() / 1000) + 86400 } ); break; case "article": const slug = session.metadata?.article_slug; if (slug) { const existing = await env.KV.get( `site:claudelab:article:${email}`, "json" ) as { slugs: string[] } | null; const slugs = existing?.slugs ?? []; // Idempotency: guard against duplicate entries on Webhook retries if (\!slugs.includes(slug)) slugs.push(slug); await env.KV.put( `site:claudelab:article:${email}`, JSON.stringify({ email, slugs }), { expirationTtl: 60 * 60 * 24 * 365 * 10 } ); } break; case "tip": console.log(`Tip received: ${email}`); break; }}
Idempotency Is Not Optional
Stripe guarantees at-least-once delivery of Webhook events, not exactly-once. The same checkout.session.completed event may arrive two or three times if your endpoint is slow to respond or returns an error.
Every KV write must be idempotent — running the same write twice must produce the same result as running it once.
For per-article purchases, the slugs.includes(slug) guard handles this. For Premium grants, overwriting the same KV key with the same value is naturally idempotent. The subscription update handler overwrites with the latest periodEnd, which is also fine.
For stricter guarantees, store the processed Stripe session ID in a separate KV namespace and skip processing if you've already handled that session ID. This adds complexity but eliminates the possibility of any duplicate side effects.
Two-Layer Access Control: KV + Cookie Fallback
The pattern that's proven most reliable in production: Cloudflare KV as the authoritative source, with the Cookie as a read fallback when KV is temporarily unavailable.
// src/lib/premium.tsexport async function canViewPremium(request: Request): Promise<boolean> { const cookieHeader = request.headers.get("cookie") ?? ""; const token = parseCookie(cookieHeader, "premium_token"); if (\!token) return false; try { const { email } = JSON.parse(atob(token)); const { env } = await getCloudflareContext(); // Tier 1: KV lookup — authoritative, reflects current subscription state const premiumKv = await env.KV.get(`site:claudelab:premium:${email}`); if (premiumKv) return true; // Also check active Pro subscription const proKv = await env.KV.get( `site:claudelab:pro:${email}`, "json" ) as { periodEnd?: number } | null; if (proKv?.periodEnd && proKv.periodEnd > Math.floor(Date.now() / 1000)) { return true; } return false; } catch { return false; }}export async function getArticleAccess(slug: string, request: Request): Promise<boolean> { const cookieHeader = request.headers.get("cookie") ?? ""; const purchases = parseCookie(cookieHeader, "article_purchases"); if (\!purchases) return false; try { const { email, slugs: cookieSlugs } = JSON.parse(atob(purchases)) as { email: string; slugs: string[]; }; const { env } = await getCloudflareContext(); // Tier 1: KV — most current purchase record const kvData = await env.KV.get( `site:claudelab:article:${email}`, "json" ) as { slugs: string[] } | null; if (kvData?.slugs?.includes(slug)) return true; // Tier 2: Cookie slug list — fallback during KV unavailability if (cookieSlugs?.includes(slug)) return true; // Deny by default — never return true as a catch-all fallback return false; } catch { return false; }}
Why Deny by Default Matters
The return false at the end of getArticleAccess isn't just a convention — it's the safety property that prevents your premium content from becoming accidentally free.
If you add return true as a "give benefit of the doubt" fallback, a KV read failure means any user can access any premium article without paying. During an incident, you'd simultaneously be dealing with KV issues and a wave of free access to paid content.
The correct security posture is always: if you can't confirm access, deny it. The user can contact support if there's a genuine error; you can't un-give away premium content.
Cookie Format for Purchase Accumulation
The Cookie stores a base64-encoded JSON object that accumulates purchased slugs across sessions:
// Appending a new purchase to the existing Cookieconst existingCookie = parseCookie(cookieHeader, "article_purchases");let currentSlugs: string[] = [];if (existingCookie) { try { const parsed = JSON.parse(atob(existingCookie)); currentSlugs = parsed.slugs ?? []; } catch {}}if (slug && \!currentSlugs.includes(slug)) { currentSlugs.push(slug);}const cookieValue = btoa(JSON.stringify({ email, slugs: currentSlugs }));// All four security attributes are requiredconst cookieOptions = [ `article_purchases=${cookieValue}`, "Max-Age=315360000", // 10 years "Path=/", "HttpOnly", // Blocks JavaScript access — XSS protection "Secure", // HTTPS only "SameSite=Lax", // CSRF protection].join("; ");
Subscription Renewals and Cancellations
Monthly subscriptions generate customer.subscription.updated events after each successful renewal. Use these to extend the KV TTL so access continues as long as the subscription is active.
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) { const email = await getEmailFromCustomer(subscription.customer as string); if (\!email) return; const { env } = await getCloudflareContext(); if (subscription.status === "active") { const periodEnd = subscription.current_period_end; const ttl = periodEnd - Math.floor(Date.now() / 1000) + 86400; // +1 day grace await env.KV.put( `site:claudelab:pro:${email}`, JSON.stringify({ plan: "pro", periodEnd, renewedAt: Date.now() }), { expirationTtl: Math.max(ttl, 86400) } ); } else if (["past_due", "canceled", "unpaid"].includes(subscription.status)) { await env.KV.delete(`site:claudelab:pro:${email}`); }}async function handleSubscriptionDelete(subscription: Stripe.Subscription) { const email = await getEmailFromCustomer(subscription.customer as string); if (email) { const { env } = await getCloudflareContext(); await env.KV.delete(`site:claudelab:pro:${email}`); }}async function getEmailFromCustomer(customerId: string): Promise<string | null> { try { const customer = await stripe.customers.retrieve(customerId); if ((customer as any).deleted) return null; return (customer as Stripe.Customer).email; } catch { return null; }}
The Grace Period Design Decision
When subscription.status becomes "canceled", immediately deleting the KV entry means a user who cancels mid-period loses access immediately — even though they've paid through the end of the billing cycle.
A more user-friendly implementation would keep the KV entry until current_period_end even after cancellation. This is more complex to implement (you need to track that the subscription is cancelled but access is still valid until a future date). For an initial launch, "cancel = immediate revoke" is a defensible simplification. Just decide consciously and document it.
Five Production Pitfalls in Detail
These are the issues that cost the most debugging time. None of them are prominently documented.
Pitfall 1: bfcache Freezes the Purchase Button
When users click the browser's back button after being redirected to Stripe Checkout, browsers restore the previous page from bfcache (Back-Forward Cache). Any loading state that was active before the redirect is restored frozen.
import { useEffect, useState } from "react";export function PurchaseButton({ planType }: { planType: string }) { const [isLoading, setIsLoading] = useState(false); useEffect(() => { const handlePageShow = (e: PageTransitionEvent) => { // e.persisted is true when the page was restored from bfcache if (e.persisted) setIsLoading(false); }; window.addEventListener("pageshow", handlePageShow); return () => window.removeEventListener("pageshow", handlePageShow); }, []); const handleCheckout = async () => { setIsLoading(true); try { const res = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ planType }), }); const { url } = await res.json(); window.location.href = url; } catch { setIsLoading(false); } }; // Use <button> not <a> — anchor elements can require two clicks return ( <button onClick={handleCheckout} disabled={isLoading}> {isLoading ? "Processing..." : "Purchase"} </button> );}
Pitfall 2: product_data.images Is Not Optional
Stripe Checkout pages don't show product descriptions prominently without a product image. This is undocumented behavior that you discover by testing. Use a square logo image (512×512px minimum) and always include it in product_data.images. Keep a branded image at /public/images/stripe-product.png for this purpose.
Pitfall 3: Webhook Response Timeout
Cloudflare Workers has a 30-second response timeout. If your Webhook handler makes multiple KV writes, sends emails, or calls external APIs, you may hit this limit. The solution is to respond immediately and continue processing in the background:
export async function POST( request: Request, { waitUntil }: { waitUntil: (p: Promise<any>) => void }) { // ... signature verification ... // Acknowledge immediately — Stripe needs a response within its timeout waitUntil(processEvent(event)); // Background processing continues return new Response("OK", { status: 200 });}
Pitfall 4: Local vs Production Webhook Secrets Are Different
The whsec_... secret from stripe listen --forward-to localhost:3000 is only valid for the local CLI session. Using it in production causes every Webhook to fail verification with a cryptographic error that looks identical to a legitimate signature failure. Keep these as completely separate environment variables with distinct names.
Pitfall 5: HttpOnly on Auth Cookies Is Non-Negotiable
Omitting HttpOnly from your auth cookies makes them readable from JavaScript, which means any XSS vulnerability in your application — however small — can exfiltrate payment auth tokens. Set all four security attributes together: HttpOnly, Secure, SameSite=Lax, and Path=/. Any one of them missing meaningfully weakens your security model.
Testing the Full Flow Before Going to Production
Manual testing is insufficient for a payment system. The scenarios that break in production are rarely the happy path — they're edge cases like duplicate Webhooks, KV unavailability, and browser behavior during redirects.
Ask Claude Code to generate tests for these four scenarios:
Scenario 1: Successful purchase. The full flow from POST /api/checkout through Webhook to KV write and Cookie issuance. Verify the Cookie contains the right email and slugs, and that getArticleAccess returns true.
Scenario 2: Webhook delivered twice. Call your Webhook handler with identical payloads twice. Verify that KV contains exactly one entry, not two, and that slug lists don't have duplicates.
Scenario 3: KV unavailable. Mock KV to throw an error. Verify that getArticleAccess returns false (not true), and that the fallback Cookie path works independently.
Scenario 4: Access after cancellation. Call handleSubscriptionDelete, then call canViewPremium. Verify that access is correctly revoked.
These four scenarios cover the failure modes most likely to cause production incidents. Building them as automated tests before launch is significantly cheaper than debugging them in production.
Implementing the verify-session Endpoint
The verify-session endpoint is what bridges Stripe's payment confirmation and your application's access control. After a successful payment, Stripe redirects to your success_url — which should be this endpoint — with a session_id query parameter.
// src/app/api/verify-session/route.tsimport Stripe from "stripe";import { getCloudflareContext } from "@opennextjs/cloudflare";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, { apiVersion: "2024-11-20.acacia",});export async function GET(request: Request) { const { searchParams } = new URL(request.url); const sessionId = searchParams.get("session_id"); const locale = searchParams.get("locale") ?? "en"; if (\!sessionId) { return Response.redirect(`/${locale}/membership`); } let session: Stripe.Checkout.Session; try { // Retrieve the session directly from Stripe — don't wait for the Webhook // The Webhook and verify-session run independently (race condition prevention) session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["customer_details"], }); } catch { return Response.redirect(`/${locale}/membership?error=session_not_found`); } const email = session.customer_details?.email; const planType = session.metadata?.plan_type; const slug = session.metadata?.article_slug; if (\!email || session.payment_status \!== "paid") { return Response.redirect(`/${locale}/membership?error=payment_not_confirmed`); } // Issue the appropriate Cookie based on plan type const headers = new Headers(); const cookieBase = "HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=315360000"; if (planType === "premium" || planType === "pro") { const tokenValue = btoa(JSON.stringify({ email, plan: planType })); headers.append("Set-Cookie", `premium_token=${tokenValue}; ${cookieBase}`); } if (planType === "article" && slug) { const existing = request.headers.get("cookie") ?? ""; const currentCookie = parseCookie(existing, "article_purchases"); let slugs: string[] = []; if (currentCookie) { try { slugs = JSON.parse(atob(currentCookie)).slugs ?? []; } catch {} } if (\!slugs.includes(slug)) slugs.push(slug); const purchaseValue = btoa(JSON.stringify({ email, slugs })); headers.append("Set-Cookie", `article_purchases=${purchaseValue}; ${cookieBase}`); } // Redirect with a thanks parameter so the UI can show a confirmation const thanksParam = planType === "tip" ? "tip" : planType === "article" ? "article" : "membership"; const destination = planType === "article" && slug ? `/${locale}/articles/${session.metadata?.article_category ?? ""}/${slug}?thanks=${thanksParam}` : `/${locale}/membership?thanks=${thanksParam}`; headers.set("Location", destination); return new Response(null, { status: 302, headers });}function parseCookie(cookieHeader: string, name: string): string | null { const match = cookieHeader.match(new RegExp(`(?:^|;\s*)${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : null;}
Why verify-session Reads from Stripe Directly
A common mistake is having verify-session read from KV to confirm the purchase. But the Webhook that writes to KV may not have been processed yet when the user hits verify-session — Stripe's redirect and the Webhook are delivered independently, and the redirect typically arrives first.
Reading from Stripe directly (stripe.checkout.sessions.retrieve) gives you authoritative confirmation of the payment without depending on the Webhook having completed. The Webhook handles KV persistence; verify-session handles Cookie issuance. They're independent.
The Thanks Redirect Pattern
After issuing the Cookie, redirect to the purchased content (or the membership page) with a ?thanks=article query parameter. Your frontend can read this parameter to show a thank-you message, then remove it from the URL with history.replaceState so the confirmation doesn't reappear on refresh.
// In your article page componentuseEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get("thanks") === "article") { setShowThankYou(true); // Clean the URL without triggering a navigation const cleanUrl = window.location.pathname; window.history.replaceState({}, "", cleanUrl); }}, []);
This pattern keeps the user on the purchased content immediately after payment — rather than sending them back to a generic success page — which noticeably improves the post-purchase experience.
One Thing I Only Noticed After Going Live
Running a paid membership as an indie developer, you discover behaviors that never reproduced once in testing but quietly happen in production. In my case it was not duplicate Webhook delivery — it was event reordering.
Stripe sends checkout.session.completed and the subscription-side customer.subscription.created at almost the same moment. Depending on network jitter, the latter sometimes arrives first. If your KV write assumes the session-completed event is authoritative, an early subscription event leaves the access record momentarily incomplete.
What I actually observed was a very short window right after payment — a few hundred milliseconds — where the member page rendered as if the user were not a member. A reload fixed it, so it was easy to miss, but it happened at the single most important moment: the first successful purchase.
The fix was simple. Instead of splitting KV keys by event type, write to a single record with a status field using a merge strategy. Whichever event arrives first only fills in the fields it knows about, so the result no longer depends on ordering.
The events that never reproduce in testing are the ones you only find by quietly watching production logs over time — a habit this project taught me more than any single bug did.
Working with Claude Code on Payment System Design
The most useful thing about implementing Stripe with Claude Code isn't the code generation — it's the ability to have a genuine design conversation.
When I asked about idempotency, Claude Code didn't just write the slug deduplication check. It explained the specific scenario: "Stripe retries failed Webhooks up to three times. With your current implementation, if your handler times out on the first delivery, the second delivery would process successfully — but if the first delivery partially succeeded (e.g., KV write completed but your handler returned 500), the second delivery would attempt to process an already-complete purchase. Here's how to handle each case."
That level of explanation — not just what to do, but which specific failure mode each change prevents — made the final implementation considerably more robust than what I would have written working alone.
Three approaches that worked particularly well:
First, start by describing the complete flow in plain language. Before any code, write out the full end-to-end flow and ask Claude Code for the dependency-aware implementation sequence. This surfaces ordering issues (like the verify-session/Webhook race condition described earlier) before they become bugs.
Second, ask for security review at each stage. "What are the security implications of this implementation?" and "What happens to access control if KV is temporarily unavailable?" were the questions that caught the most issues, including the deny-by-default violation that would have made all premium content free during incidents.
Third, request explicit edge case test scenarios. "Write tests covering: successful purchase, duplicate Webhook delivery, KV read failure, and access revocation after cancellation" produces test cases that actually cover the production failure modes, not just the happy path.
Payment systems sit at the intersection of security, user trust, and revenue. Getting them wrong has immediate, tangible consequences. That makes them one of the areas where working through the design carefully with Claude Code — rather than just asking it to generate code — delivers the most value.
For deeper coverage of security patterns in Cloudflare Workers environments, see Claude API Production Security Complete Guide.
The most practical starting point for your own implementation: build pricing.ts first. Having all price IDs, display values, and UI copy in one place makes every subsequent implementation decision cleaner and every future price change a single-file operation.
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.