The first time I pointed a Stripe test webhook at my Workers deployment, the dashboard filled with red 400s. I re-pasted the signing secret three times, suspected a stray newline, even generated a fresh endpoint — and got the same result every time. The secret was never the problem. The function I was verifying with was.
This is the implementation companion to my Gemini API paid-service launch roadmap. It walks through wiring Stripe and the Gemini API together on Cloudflare Workers as a subscription SaaS, following the setup I use on my own site.
The first thing I'd like to say is that the heart of this build isn't model selection — it's deciding, in exactly one place, who counts as a paying member. The Gemini call itself is a few dozen lines. The time went into webhooks, cancellations, and caching.
The code assumes Next.js (App Router) on Cloudflare Workers via OpenNext, the Stripe Node SDK, and @google/genai. Versions move quickly, so please compare against your own package.json and the official docs as you read. I also cover the webhook side from another angle in the Stripe billing webhook production guide.
Reference Architecture
- Auth: NextAuth.js (email + OAuth)
- Payments: Stripe Checkout (subscription and one-time purchase)
- Access control: Stripe webhook → Cloudflare KV (a permission flag per user)
- API layer: Cloudflare Workers +
@google/genai - Model switching: Flash by default, Pro only where it earns its cost
KV is the single source of truth for access. Asking the Stripe API on every request costs you in both rate limits and latency. I think of Stripe as the record of money and KV as the badge that says whether someone can walk in right now; splitting the roles that way also makes failures easier to localize.
Step 1: Stripe Product Design
Create products and prices in the Stripe dashboard, then put the resulting price IDs in environment variables. Separate Prices per currency keep the amount shown at Checkout matched to the locale.
- Pro monthly (JPY / USD): recurring subscription
- Single purchase (JPY / USD): a 24-hour pass
I deliberately don't fix amounts here. I believe the monthly price should come from your own invoices — work out per-user monthly cost first. I did it backwards the first time, settling on the phrase "unlimited" before checking margins, and repricing afterwards was more work than it should have been.
# wrangler.toml
[vars]
STRIPE_PRICE_PRO_JPY = "price_..."
STRIPE_PRICE_PRO_USD = "price_..."
STRIPE_PRICE_SINGLE_JPY = "price_..."
STRIPE_PRICE_SINGLE_USD = "price_..."
SITE_URL = "https://example.com"
# Secrets are managed separately with `wrangler secret put`
# - STRIPE_SECRET_KEY
# - STRIPE_WEBHOOK_SECRET
# - GEMINI_API_KEY
# - NEXTAUTH_SECRETStep 2: Checkout Session Endpoint
Don't trust plan and locale from the client as-is. Decide the allowed values up front and return 400 for anything else.
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
const PLANS = ['pro', 'single'] as const;
const LOCALES = ['ja', 'en'] as const;
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const { plan, locale } = await req.json();
if (!PLANS.includes(plan) || !LOCALES.includes(locale)) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
httpClient: Stripe.createFetchHttpClient(), // use the fetch-based client on Workers
});
const suffix = locale === 'ja' ? 'JPY' : 'USD';
const priceId = process.env[`STRIPE_PRICE_${plan.toUpperCase()}_${suffix}`]!;
const isPro = plan === 'pro';
const checkout = await stripe.checkout.sessions.create({
mode: isPro ? 'subscription' : 'payment',
line_items: [{ price: priceId, quantity: 1 }],
customer_email: session.user.email,
success_url: `${process.env.SITE_URL}/${locale}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/${locale}/membership`,
metadata: { plan_type: plan, user_email: session.user.email },
});
return NextResponse.json({ url: checkout.url });
}Always set metadata.plan_type. In the webhook it's the only clue you'll have for telling a subscription from a one-time purchase.
One more note: with OpenNext on Workers, leaving export const runtime = 'edge' in a route can make the build complain. In my setup, removing that line and staying on the Node.js runtime (nodejs_compat) was the stable choice. Please check the docs for your OpenNext version once.
Step 3: Stripe Webhook
The opening 400s were born here. The synchronous constructEvent that appears in most Node samples doesn't get along with the Workers crypto API (SubtleCrypto is asynchronous), so verification fails even when the signature is valid. On Workers, use constructEventAsync.
My first version also had an idempotency trap: it recorded the event ID in KV before processing. If processing crashed midway, Stripe's retry arrived and was discarded as "already handled." Record after the work is done. That's the order I use now.
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
const body = await req.text(); // verification needs the raw, untouched text
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
httpClient: Stripe.createFetchHttpClient(),
});
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
body, sig!, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
const { env } = getCloudflareContext();
const kv = env.MEMBERSHIP_KV;
// Skip events we've already handled (we record at the very end)
const eventKey = `site:example:webhook:${event.id}`;
if (await kv.get(eventKey)) {
return NextResponse.json({ received: true, duplicate: true });
}
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
const email = s.customer_email ?? s.metadata?.user_email;
if (!email) break;
if (s.metadata?.plan_type === 'pro') {
const sub = await stripe.subscriptions.retrieve(s.subscription as string);
// Where the period end lives depends on your Stripe API version
const periodEnd =
sub.items?.data?.[0]?.current_period_end ??
(sub as unknown as { current_period_end: number }).current_period_end;
const ttl = periodEnd - Math.floor(Date.now() / 1000) + 86400 * 3;
await kv.put(
`site:example:member:${email}`,
JSON.stringify({ plan: 'pro', current_period_end: periodEnd, subscription_id: sub.id }),
{ expirationTtl: Math.max(ttl, 60) } // KV's minimum TTL is 60 seconds
);
} else if (s.metadata?.plan_type === 'single') {
await kv.put(`site:example:single:${email}`, '1', { expirationTtl: 86400 });
}
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(sub.customer as string);
if (!('deleted' in customer) && customer.email) {
await kv.delete(`site:example:member:${customer.email}`);
}
break;
}
}
await kv.put(eventKey, '1', { expirationTtl: 86400 * 7 });
return NextResponse.json({ received: true });
}I set the TTL to "next renewal date + 3 days," so even if a renewal webhook never arrives, the badge expires on its own instead of granting access forever. The flip side is that you also need to extend the TTL on each renewal (for example from invoice.paid). The code here is a minimal skeleton, so please add renewal handling before production.
Step 4: Authorization Helper
// lib/premium.ts
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export async function canViewPremium(): Promise<boolean> {
const session = await getServerSession(authOptions);
if (!session?.user?.email) return false;
try {
const { env } = getCloudflareContext();
const raw = await env.MEMBERSHIP_KV.get(`site:example:member:${session.user.email}`);
if (raw && JSON.parse(raw).plan === 'pro') return true;
return (await env.MEMBERSHIP_KV.get(`site:example:single:${session.user.email}`)) === '1';
} catch {
return false; // if KV can't be read, stay closed
}
}Please never change the return false in the catch, however rushed you are. Flip it to true and every KV outage turns into free access for everyone.
Keep the truth about access in one place, and when in doubt, lean toward closed. I hold to that line even the night before a deadline.
Step 5: Calling the Gemini API
This is the Gemini-specific part. I use @google/genai; the older @google/generative-ai has moved on to its successor, so for a new build it's simplest to start with the new one. Two tiers, Flash and Pro, with model names kept in a single table. Model IDs turn over quickly, so check the official model list for current names before shipping.
// lib/gemini.ts
import { GoogleGenAI } from '@google/genai';
export type ModelTier = 'flash' | 'pro';
const MODEL_NAMES: Record<ModelTier, string> = {
flash: 'gemini-3.2-flash',
pro: 'gemini-3.2-pro',
};
export async function generateWithGemini(
prompt: string,
tier: ModelTier = 'flash'
): Promise<string> {
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
const res = await ai.models.generateContent({
model: MODEL_NAMES[tier],
contents: prompt,
config: { maxOutputTokens: 2000, temperature: 0.7 },
});
return res.text ?? '';
}Always set maxOutputTokens explicitly. Together with input validation, it puts a ceiling on the worst-case cost of one call.
Step 6: A Rate-Limited Endpoint
Even if the plan says "unlimited," you still need rate limiting. "Unlimited" describes the monthly price; it doesn't mean any number of simultaneous requests.
// app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { canViewPremium } from '@/lib/premium';
import { generateWithGemini, type ModelTier } from '@/lib/gemini';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
if (!(await canViewPremium())) {
return NextResponse.json({ error: 'subscription_required' }, { status: 402 });
}
const { prompt, tier = 'flash' } = await req.json();
if (typeof prompt !== 'string' || prompt.length > 8000) {
return NextResponse.json({ error: 'bad_request' }, { status: 400 });
}
if (tier !== 'flash' && tier !== 'pro') {
return NextResponse.json({ error: 'bad_tier' }, { status: 400 });
}
// Roughly 10 requests per minute
const { env } = getCloudflareContext();
const minuteKey = `rate:${session.user.email}:${Math.floor(Date.now() / 60000)}`;
const current = parseInt((await env.MEMBERSHIP_KV.get(minuteKey)) ?? '0', 10);
if (current >= 10) {
return NextResponse.json({ error: 'rate_limit', retryAfter: 60 }, { status: 429 });
}
await env.MEMBERSHIP_KV.put(minuteKey, String(current + 1), { expirationTtl: 120 });
const text = await generateWithGemini(prompt, tier as ModelTier);
return NextResponse.json({ text });
}To be candid: between the read and the write, other requests can slip in, and KV writes take time to propagate. This is not an exact counter; it's a brake that stops runaway loops. If you need a limit tied directly to your bill, I'd move to something that counts atomically, such as Durable Objects or Cloudflare's rate limiting feature.
Step 7: Cancellation and Edge Cases
Three places that tend to bite after launch.
Last-minute use after cancelling — When someone cancels through the Customer Portal, access normally continues to the end of the period. The KV badge stays until customer.subscription.deleted arrives, and its TTL already matches the period end, so no extra handling is needed. Read cancel_at_period_end from customer.subscription.updated only if you want to show a "cancellation scheduled" notice.
Duplicate webhooks — The same event can arrive more than once. Step 3 handles it by recording event.id.
Gemini quota exhaustion — It comes back as HTTP 429 (RESOURCE_EXHAUSTED). SDK errors carry a status, so checking the status is more reliable than matching message text.
async function generateWithRetry(prompt: string, tier: ModelTier): Promise<string> {
try {
return await generateWithGemini(prompt, tier);
} catch (e) {
const status = (e as { status?: number }).status;
if (status === 429 || (status !== undefined && status >= 500)) {
await new Promise((r) => setTimeout(r, 1500)); // wait briefly, retry once
return await generateWithGemini(prompt, tier);
}
throw e;
}
}Falling back from Flash to Pro is an option, but quotas are sometimes per model and sometimes a project-wide ceiling, and in the latter case it won't help. Where I've landed: wait and retry once, and if that fails, tell the user plainly that things are busy.
Step 8: Cloudflare Edge Cache
This is the trap indie SaaS builders miss most. Cache HTML at the edge and everyone gets the same HTML regardless of login or billing state. "I paid but the membership prompt won't go away" is nearly always this.
Requests that carry a member cookie (in my setup, premium_token) should bypass the cache and go to the origin.
addEventListener('fetch', (event) => {
const cookie = event.request.headers.get('cookie') || '';
if (cookie.includes('premium_token')) {
event.respondWith(fetch(event.request)); // members skip the cache
return;
}
event.respondWith(handleCachedRequest(event));
});Pre-Launch Checklist
- The Stripe webhook endpoint points at the production URL
STRIPE_WEBHOOK_SECRETandGEMINI_API_KEYare the production values- KV key prefixes can't collide with other services sharing the namespace
- A monthly budget alert is set on the Google Cloud side
- Stripe test mode: purchase → cancel → repurchase, all three paths exercised
- A webhook with a bad signature returns 400 (checked with curl)
- The rate limit really does return 429
- The member-cookie cache bypass works
Looking Back
When I finished, very little was Gemini-specific. What remained: two model tiers chosen by the caller, a ceiling on output tokens, and a price derived from my own invoices. Everything else — async signature checks, the fetch-based HTTP client, edge caching, writing webhooks on the assumption they will be redelivered — was about the Workers environment, and that's what held the foundation up.
If you're building this, start in test mode and confirm a bad signature gets a 400. That's where I began rebuilding, one piece at a time — and I can laugh now about those red 400s at the start: it was never the key.