◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/API / SDK
◈ API / SDK/2026-05-03Advanced

Building a Subscription SaaS on Gemini API and Cloudflare Workers — A Complete 2026 Implementation Guide

How I wire Gemini API, Stripe, and Cloudflare Workers into a subscription SaaS — with code, and with the places that actually bite: signature checks on Workers, webhook idempotency, KV access control, and the limits of a KV rate limiter.

Gemini API249Stripe10Cloudflare Workers6SaaS11Implementation

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_SECRET

Step 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_SECRET and GEMINI_API_KEY are 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.

Share

Thank You for Reading

Gemini 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 →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◈ API / SDK2026-04-26
Gemini API × Stripe — Production Usage-Based Billing for Indie AI SaaS
A complete guide to building a usage-based billing system for your Gemini API SaaS using Stripe Metered Billing and webhooks — production patterns included.
◈ API / SDK2026-05-04
Solving Gemini API Cold Starts — Production-Grade Startup Optimization for Cloud Run, Lambda, and Workers
When you put Gemini API on serverless, the first request takes six seconds. This guide breaks down where the time goes and shows concrete startup-optimization patterns for Cloud Run, AWS Lambda, and Cloudflare Workers — with real numbers, runnable code, and cost trade-off advice.
◈ API / SDK2026-05-03
A Blueprint for Building a Profitable Indie SaaS on the Gemini API
How to take Gemini's long context, native multimodality, and generous free tier and build them into a recurring-revenue SaaS as a solo founder. Pricing tiers, cost routing rules, and a 90-day plan to your first $1,000 of MRR.