In the companion roadmap I argued that agent-driven SaaS sells time savings, not features. This article is the implementation half: the actual code for shipping a billing, production-grade agent SaaS on Antigravity + Stripe + Cloudflare Workers.
The Antigravity Lab content automation system itself is built on a simpler version of the architecture you'll see here. Webhook failures, agent timeouts, runaway concurrency — these are all production scars I'm collapsing into reusable patterns.
The code targets Next.js 16 App Router on Cloudflare Workers via OpenNext, with Stripe v15, Anthropic SDK, and Cloudflare Queues. Agent execution is too slow to fit a synchronous HTTP handler — queue-based async execution is the standard structure.
Reference Architecture
- Auth: NextAuth.js
- Billing: Stripe Checkout (subscription + one-shot credits)
- Authorization & credits: Stripe Webhook → Cloudflare KV
- Task queue: Cloudflare Queues for async agent execution
- Agent execution layer: Cloudflare Workers + Anthropic SDK
- State store: D1 (task history and results)
The crucial architectural move: separate HTTP request handling from agent execution. When the user submits a form, return a task ID immediately and enqueue the work; a separate worker processes the queue. This keeps UX responsive even when individual agent runs take minutes.
Step 1: Stripe Product Design
Agent SaaS defaults to "flat monthly + per-task cap" pricing.
- Pro Monthly (JPY): "Pro Agent Plan," ¥1,500/month, 10 tasks/month
- Pro Monthly (USD): "Pro Agent Plan," $10/month, 10 tasks/month
- Additional Credits (JPY/USD): ¥300 / $2 per credit
Step 2: Checkout Endpoint
Identical pattern to the Claude × Stripe guide, with agent-specific metadata:
// 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';
export const runtime = 'edge';
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();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
const isJa = locale === 'ja';
const isPro = plan === 'pro';
const params: Stripe.Checkout.SessionCreateParams = {
mode: isPro ? 'subscription' : 'payment',
success_url: `${process.env.SITE_URL}/${locale}/thanks?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/${locale}/membership`,
customer_email: session.user.email,
line_items: [{
price_data: {
currency: isJa ? 'jpy' : 'usd',
recurring: isPro ? { interval: 'month' } : undefined,
unit_amount: isPro ? (isJa ? 1500 : 1000) : (isJa ? 300 : 200),
product_data: {
name: isPro ? 'Pro Agent Plan (Monthly)' : 'Additional Task Credit',
description: isPro
? '10 agent task executions per month'
: 'One additional task credit',
images: [`${process.env.SITE_URL}/images/stripe-product.png`],
},
},
quantity: 1,
}],
metadata: {
plan_type: plan,
task_credits: isPro ? '10' : '1',
user_email: session.user.email,
},
};
const checkoutSession = await stripe.checkout.sessions.create(params);
return NextResponse.json({ url: checkoutSession.url });
}metadata.task_credits is the field the webhook will use to update the user's credit balance in KV. Pro subscribers get 10/month auto-replenished; one-shot credit purchases simply increment.
Step 3: Webhook for Task Credit Management
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const sig = req.headers.get('stripe-signature');
const body = await req.text();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-10-31',
});
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (e) {
return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
}
const { env } = getCloudflareContext();
const kv = env.MEMBERSHIP_KV;
// Idempotency
const eventKey = `site:antigravity:webhook:${event.id}`;
if (await kv.get(eventKey)) {
return NextResponse.json({ received: true, duplicate: true });
}
await kv.put(eventKey, '1', { expirationTtl: 86400 * 7 });
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
const planType = s.metadata?.plan_type;
const email = s.customer_email ?? s.metadata?.user_email;
const taskCredits = parseInt(s.metadata?.task_credits ?? '0', 10);
if (!email) break;
if (planType === 'pro') {
const subscription = await stripe.subscriptions.retrieve(s.subscription as string);
await kv.put(
`site:antigravity:member:${email}`,
JSON.stringify({
plan: 'pro',
current_period_end: subscription.current_period_end,
subscription_id: subscription.id,
}),
{ expirationTtl: subscription.current_period_end - Math.floor(Date.now() / 1000) + 86400 * 3 }
);
await addTaskCredits(kv, email, 10);
} else if (planType === 'credits') {
await addTaskCredits(kv, email, taskCredits);
}
break;
}
case 'invoice.payment_succeeded': {
// Replenish credits at each renewal
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason === 'subscription_cycle') {
const customer = await stripe.customers.retrieve(invoice.customer as string);
if ('email' in customer && customer.email) {
await addTaskCredits(kv, customer.email, 10);
}
}
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const customer = await stripe.customers.retrieve(sub.customer as string);
if ('email' in customer && customer.email) {
await kv.delete(`site:antigravity:member:${customer.email}`);
}
break;
}
}
return NextResponse.json({ received: true });
}
async function addTaskCredits(kv: any, email: string, amount: number) {
const key = `site:antigravity:credits:${email}`;
const current = parseInt((await kv.get(key)) ?? '0', 10);
await kv.put(key, String(current + amount), { expirationTtl: 86400 * 365 });
}The invoice.payment_succeeded handler with billing_reason === 'subscription_cycle' is what makes monthly credit replenishment work. Pro subscribers get fresh credits at each renewal automatically — no manual intervention required.
Step 4: Task Submission API and Queue Enqueue
When a user submits a task, enqueue it and return immediately:
// app/api/tasks/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getCloudflareContext } from '@opennextjs/cloudflare';
export const runtime = 'edge';
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
const email = session.user.email;
const { env } = getCloudflareContext();
// Check credit balance
const credits = parseInt(
(await env.MEMBERSHIP_KV.get(`site:antigravity:credits:${email}`)) ?? '0',
10
);
if (credits < 1) {
return NextResponse.json(
{ error: 'no_credits', message: 'Out of task credits' },
{ status: 402 }
);
}
// Concurrency check (max 3 in-flight)
const runningKey = `site:antigravity:running:${email}`;
const running = parseInt((await env.MEMBERSHIP_KV.get(runningKey)) ?? '0', 10);
if (running >= 3) {
return NextResponse.json(
{ error: 'too_many_concurrent', message: 'Too many concurrent tasks' },
{ status: 429 }
);
}
const { taskType, prompt } = await req.json();
const taskId = crypto.randomUUID();
// Decrement credit, increment running counter
await env.MEMBERSHIP_KV.put(
`site:antigravity:credits:${email}`,
String(credits - 1),
{ expirationTtl: 86400 * 365 }
);
await env.MEMBERSHIP_KV.put(runningKey, String(running + 1), { expirationTtl: 600 });
// Persist task record
await env.DB.prepare(
`INSERT INTO tasks (id, email, type, prompt, status, created_at) VALUES (?, ?, ?, ?, ?, ?)`
).bind(taskId, email, taskType, prompt, 'queued', Date.now()).run();
// Enqueue
await env.AGENT_QUEUE.send({ taskId, email, taskType, prompt });
return NextResponse.json({ taskId, status: 'queued' });
}Step 5: Queue Worker for Agent Execution
A separate worker consumes the queue and runs the agent:
// workers/agent-worker.ts
import Anthropic from '@anthropic-ai/sdk';
export default {
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const message of batch.messages) {
const { taskId, email, taskType, prompt } = message.body as any;
try {
await processAgentTask(taskId, email, taskType, prompt, env);
} catch (e: any) {
await env.DB.prepare(
`UPDATE tasks SET status = 'failed', error = ?, completed_at = ? WHERE id = ?`
).bind(e.message, Date.now(), taskId).run();
// Refund credit on failure
await refundCredit(env, email);
} finally {
await decrementRunning(env, email);
}
}
},
};
async function processAgentTask(
taskId: string, email: string, taskType: string, prompt: string, env: Env
): Promise<void> {
await env.DB.prepare(
`UPDATE tasks SET status = 'running', started_at = ? WHERE id = ?`
).bind(Date.now(), taskId).run();
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
const steps = await planSteps(anthropic, taskType, prompt);
const results: string[] = [];
for (const step of steps) {
const stepResult = await executeStep(anthropic, step);
results.push(stepResult);
// 10-minute task timeout
const task = await env.DB.prepare(
`SELECT started_at FROM tasks WHERE id = ?`
).bind(taskId).first();
if (Date.now() - (task?.started_at as number) > 10 * 60 * 1000) {
throw new Error('Task timeout exceeded');
}
}
const finalReport = await generateReport(anthropic, results);
await env.DB.prepare(
`UPDATE tasks SET status = 'completed', result = ?, completed_at = ? WHERE id = ?`
).bind(finalReport, Date.now(), taskId).run();
}Returning the user's credit on failure is the right default. Charging for a failed task once is a one-time annoyance; charging repeatedly across a flaky integration burns trust quickly.
Step 6: Timeout and Partial-Failure Handling
The two situations that always show up in agent SaaS production:
Timeouts. When the agent steps further than expected, you risk hitting Cloudflare's 30-second worker limit. The pattern above splits this into a soft 10-minute "task timeout" tracked in D1 — exceeded steps fail the task, refund the credit, and surface a clear message instead of a server error.
Partial failures. Multi-step tasks that die at step 7 of 10 should preserve the work from steps 1–6. Persist intermediate results as you go:
await env.DB.prepare(
`INSERT INTO task_steps (task_id, step_index, result) VALUES (?, ?, ?)`
).bind(taskId, stepIndex, stepResult).run();When a task fails, the user can be shown "we got this far before encountering an issue" — which is far better UX than "task failed, sorry."
Step 7: Preventing Cost Runaway
The most terrifying agent SaaS bug is an infinite-loop that quietly burns your monthly Anthropic budget. I've personally hit two variants in development — including one where the agent's plan included spawning subagents that recursively spawned more subagents. Without guardrails, this would have produced a billing event I'd rather not discuss.
Three required layers of defense:
- Max steps per task —
MAX_STEPS = 20, fail-stop on overrun - Max tokens per task —
MAX_TOKENS = 200000, fail-stop on overrun - Anthropic Console budget alerts — email at 80% of monthly budget
let totalTokens = 0;
const MAX_TOKENS = 200000;
const MAX_STEPS = 20;
let stepCount = 0;
for (const step of steps) {
if (stepCount >= MAX_STEPS) throw new Error('Max steps exceeded');
if (totalTokens >= MAX_TOKENS) throw new Error('Token budget exceeded');
const result = await executeStep(anthropic, step);
totalTokens += result.usage.input_tokens + result.usage.output_tokens;
stepCount++;
}These three together make six-figure billing accidents structurally hard to produce. They are the most important code in the system.
Step 8: Cloudflare Edge Cache Awareness
Same as the other site implementations — paid users need cache bypass for membership-aware UI (project STUMBLING_POINTS #73):
addEventListener('fetch', event => {
const req = event.request;
const cookie = req.headers.get('cookie') || '';
if (cookie.includes('premium_token')) {
event.respondWith(fetch(req));
return;
}
event.respondWith(handleCachedRequest(event));
});Pre-Launch Checklist
- Stripe webhook URL points to production
STRIPE_WEBHOOK_SECRETandANTHROPIC_API_KEYare live values- KV keys namespaced under
site:antigravity: - Cloudflare Queues binding present in
wrangler.toml - D1
tasksandtask_stepstables created - Anthropic Console monthly budget alerts configured
- Test mode walk-through: subscribe → first month → renewal → cancel
- MAX_STEPS / MAX_TOKENS guardrails verified by deliberate overrun
cache-worker.jspremium_token cookie bypass verified
Closing — Three Decisions That Make This Work
The architectural decisions specific to agent-driven SaaS, all of which separate this implementation from a generic API-wrapper SaaS:
- Queue-driven async execution — long-running tasks are decoupled from HTTP, so user experience stays snappy even when an agent run takes minutes
- Per-user concurrency control — capping in-flight tasks per user prevents API quota exhaustion and protects fair-use across the customer base
- Three-layer cost runaway protection — step cap, token cap, and budget alerts together turn the worst possible agent failure mode (six-figure surprise bill) into a contained error message
Get these three right and an Antigravity-built agent SaaS is structurally ready for production from day one. Combined with the pricing approach in the roadmap, you have a product that bills correctly and refuses to accidentally bankrupt you.
If you get stuck, the four sites I run on this stack — Antigravity Lab, Claude Lab, Gemini Lab, Rork Lab — are working references.