Many indie developers want to build products powered by AI agents, but hit a wall: they finish tutorials but can't build something people would pay for.
The official AgentKit 2.0 documentation is solid as an API reference, but it doesn't answer the questions that matter when you're running a real service: where does billing logic go, how do you vary agent behavior by plan, how do you handle duplicate Webhooks?
This guide is based on an AI agent SaaS I've actually built and operated. I'll walk through the architecture and implementation that takes an indie developer from zero to production — with Antigravity, AgentKit 2.0, and Stripe.
Why AgentKit 2.0 × Stripe?
The key difference from AgentKit 1.x
If you've tried building a service on AgentKit 1.x, you've probably run into two problems: callbacks on task completion were awkward to wire up, and managing sessions for parallel agents was overly complex.
AgentKit 2.0 fixes this at the architectural level. The most important change: agent lifecycles are now Observable.
// AgentKit 1.x — you had to poll to detect completion
const task = await agentkit.run({ prompt, tools });
// ... custom polling logic required
// AgentKit 2.0 — receive state changes via async Observable stream
import { AgentKit, AgentEvent } from "@google/agentkit";
const agent = new AgentKit({ model: "gemma-4-27b-it" });
const stream = agent.stream({
prompt: "Answer the user's question and perform any necessary analysis",
tools: [searchTool, analysisTool],
context: { userId, planType },
});
for await (const event of stream) {
if (event.type === AgentEvent.TOOL_CALL) {
// Increment usage counter on every tool call
await incrementUsage(userId, event.toolName);
}
if (event.type === AgentEvent.COMPLETE) {
await finalizeSession(userId, event.output);
}
}This Observable stream is what makes precise metered billing possible in a SaaS context.
Why Stripe pairs so well
Stripe has a usage-based billing API. Combined with AgentKit 2.0's Observable lifecycle, you can build a SaaS that charges per agent tool call in a few hundred lines of code. That's the shortest path to an AI-powered service that generates real revenue.
System Architecture
Here's the full picture of what we're building.
[User]
↓ API request (JWT)
[Next.js API Route / rate limiter]
↓ plan check
[Stripe Customer Portal / KV cache]
↓ if within plan limits
[AgentKit 2.0 Session Manager]
↓ launch agent
[Agent Workers × n (parallel limit enforced by plan)]
↓ on each tool call
[Usage Tracker → Stripe Usage API]
↓ monthly rollup
[Stripe Invoice → user billed]
We're using a three-tier plan design:
- Free: 20 agent calls/day, 1 parallel, 50 tool calls/month cap
- Pro (¥1,980/mo): 200 calls/day, 3 parallel, 500 tool calls/month
- Business (¥9,800/mo): Unlimited, 10 parallel, priority queue
Why limit by parallel agents? Simple: compute costs scale directly with parallelism. Without a limit, even well-intentioned Free users can exhaust shared resources by firing concurrent requests.
Project Setup in Antigravity
Open Antigravity and enter this in the chat:
Create a Next.js 15 (App Router) + TypeScript project with this structure:
- src/lib/agentkit/ — AgentKit 2.0 session management
- src/lib/stripe/ — Stripe billing logic
- src/lib/usage/ — usage tracking
- src/app/api/agent/ — agent execution API
- src/app/api/webhook/ — Stripe Webhook
Environment variables in .env.local: AGENTKIT_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
After Antigravity scaffolds the project, add dependencies:
npm install @google/agentkit stripe @upstash/redisUpstash Redis handles real-time usage tracking and rate limiting. Vercel KV works too, but Upstash's INCR + EXPIREAT atomic operations handle concurrent counter increments without race conditions.
Plan Management and Billing Logic
Creating Stripe Customers and Mapping Plans
// src/lib/stripe/customer.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, {
apiVersion: "2025-01-01",
});
export const PLAN_CONFIG = {
free: {
dailyCallLimit: 20,
maxParallelAgents: 1,
monthlyToolCallLimit: 50,
stripePriceId: null,
},
pro: {
dailyCallLimit: 200,
maxParallelAgents: 3,
monthlyToolCallLimit: 500,
stripePriceId: process.env.STRIPE_PRO_PRICE_ID\!,
},
business: {
dailyCallLimit: Infinity,
maxParallelAgents: 10,
monthlyToolCallLimit: Infinity,
stripePriceId: process.env.STRIPE_BUSINESS_PRICE_ID\!,
},
} as const;
export type PlanType = keyof typeof PLAN_CONFIG;
export async function getActivePlan(
stripeCustomerId: string
): Promise<PlanType> {
try {
const subscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
status: "active",
limit: 1,
});
if (subscriptions.data.length === 0) return "free";
const priceId = subscriptions.data[0].items.data[0].price.id;
if (priceId === PLAN_CONFIG.business.stripePriceId) return "business";
if (priceId === PLAN_CONFIG.pro.stripePriceId) return "pro";
return "free";
} catch (error) {
// Fail safe: on error, default to the most restricted plan (deny by default)
console.error("Plan fetch error, defaulting to free:", error);
return "free";
}
}The fallback to "free" on error is intentional. This is the deny-by-default principle: when you can't verify billing status, restrict access. The alternative — granting full access on error — creates an exploitable vulnerability.
Real-Time Usage Tracking
// src/lib/usage/tracker.ts
import { Redis } from "@upstash/redis";
import { PLAN_CONFIG, PlanType } from "@/lib/stripe/customer";
const redis = Redis.fromEnv();
export async function checkAndIncrementUsage(
userId: string,
planType: PlanType
): Promise<{ allowed: boolean; reason?: string }> {
const config = PLAN_CONFIG[planType];
const today = new Date().toISOString().split("T")[0];
const month = today.slice(0, 7);
const dailyKey = `usage:daily:${userId}:${today}`;
const monthlyKey = `usage:monthly:${userId}:${month}`;
// Atomically increment, then check limits
const pipeline = redis.pipeline();
pipeline.incr(dailyKey);
pipeline.incr(monthlyKey);
pipeline.expireat(dailyKey, getNextMidnightTimestamp());
pipeline.expireat(monthlyKey, getEndOfMonthTimestamp());
const results = await pipeline.exec() as [number, number, number, number];
const [newDaily] = results;
if (
config.dailyCallLimit \!== Infinity &&
newDaily > config.dailyCallLimit
) {
// Undo the increment
await redis.decr(dailyKey);
return {
allowed: false,
reason: `Daily limit of ${config.dailyCallLimit} agent calls reached`,
};
}
return { allowed: true };
}
function getNextMidnightTimestamp(): number {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return Math.floor(tomorrow.getTime() / 1000);
}
function getEndOfMonthTimestamp(): number {
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
return Math.floor(end.getTime() / 1000);
}The Agent Execution API
// src/app/api/agent/run/route.ts
import { NextRequest } from "next/server";
import { AgentKit, AgentEvent } from "@google/agentkit";
import { getActivePlan, PLAN_CONFIG } from "@/lib/stripe/customer";
import { checkAndIncrementUsage } from "@/lib/usage/tracker";
import { verifyAuthToken } from "@/lib/auth";
export const maxDuration = 300; // 5 minutes (Vercel Pro+)
export const runtime = "nodejs"; // Edge Runtime can't extend timeouts
export async function POST(req: NextRequest) {
// 1. Auth check
const authResult = await verifyAuthToken(req);
if (\!authResult.ok) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId, stripeCustomerId } = authResult;
// 2. Plan check (60s KV cache to avoid hammering Stripe API)
const planType = await getActivePlan(stripeCustomerId);
const planConfig = PLAN_CONFIG[planType];
// 3. Usage check & increment
const usageResult = await checkAndIncrementUsage(userId, planType);
if (\!usageResult.allowed) {
return Response.json(
{ error: usageResult.reason, upgradeUrl: "/pricing" },
{ status: 429 }
);
}
const { prompt, tools: requestedTools } = await req.json();
// 4. Stream agent execution via SSE
const encoder = new TextEncoder();
const stream = new TransformStream();
const writer = stream.writable.getWriter();
(async () => {
try {
const agent = new AgentKit({
model: planType === "business" ? "gemma-4-27b-it" : "gemma-4-9b-it",
maxParallelTools: planConfig.maxParallelAgents,
});
let toolCallCount = 0;
for await (const event of agent.stream({
prompt,
tools: requestedTools,
context: { userId, planType },
})) {
if (event.type === AgentEvent.TOOL_CALL) {
toolCallCount++;
if (
planConfig.monthlyToolCallLimit \!== Infinity &&
toolCallCount > planConfig.monthlyToolCallLimit
) {
await writer.write(
encoder.encode(
`data: ${JSON.stringify({ type: "error", message: "Monthly tool call limit reached" })}\n\n`
)
);
break;
}
}
if (event.type === AgentEvent.TEXT_CHUNK) {
await writer.write(
encoder.encode(`data: ${JSON.stringify({ type: "chunk", text: event.text })}\n\n`)
);
}
if (event.type === AgentEvent.COMPLETE) {
await writer.write(
encoder.encode(
`data: ${JSON.stringify({ type: "complete", output: event.output, usage: { toolCalls: toolCallCount } })}\n\n`
)
);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : "Agent execution error";
await writer.write(
encoder.encode(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
);
} finally {
await writer.close();
}
})();
return new Response(stream.readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}Stripe Webhook Implementation
The Webhook handler is where most SaaS implementations go wrong. The critical concept: idempotency. Stripe re-sends the same event when it doesn't receive a 200 response in time, so your handler must tolerate processing the same event twice.
// src/app/api/webhook/route.ts
import Stripe from "stripe";
import { Redis } from "@upstash/redis";
import { updateUserPlan } from "@/lib/db/users";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY\!, {
apiVersion: "2025-01-01",
});
const redis = Redis.fromEnv();
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")\!;
let event: Stripe.Event;
try {
// constructEventAsync + SubtleCryptoProvider is required for Edge/Workers environments
event = await stripe.webhooks.constructEventAsync(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET\!,
undefined,
Stripe.createSubtleCryptoProvider()
);
} catch {
return Response.json({ error: "Webhook signature verification failed" }, { status: 400 });
}
// Idempotency check: skip events we've already processed
const idempotencyKey = `webhook:processed:${event.id}`;
const alreadyProcessed = await redis.get(idempotencyKey);
if (alreadyProcessed) {
return Response.json({ received: true, skipped: true });
}
// Acquire processing lock (TTL: 60s)
await redis.setex(idempotencyKey, 60, "processing");
try {
switch (event.type) {
case "customer.subscription.created":
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
const priceId = subscription.items.data[0].price.id;
const status = subscription.status;
let planType = "free";
if (status === "active" || status === "trialing") {
if (priceId === process.env.STRIPE_PRO_PRICE_ID) planType = "pro";
if (priceId === process.env.STRIPE_BUSINESS_PRICE_ID) planType = "business";
}
await updateUserPlan(customerId, planType);
await redis.del(`plan:cache:${customerId}`);
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await updateUserPlan(sub.customer as string, "free");
await redis.del(`plan:cache:${sub.customer as string}`);
break;
}
case "invoice.payment_failed": {
// Don't immediately downgrade — respect Stripe's dunning grace period (default 3 days)
// Downgrade happens when subscription status becomes "past_due"
const invoice = event.data.object as Stripe.Invoice;
console.warn(`Payment failed for customer: ${invoice.customer}`);
break;
}
}
// Extend lock to 24h after successful processing
await redis.setex(idempotencyKey, 86400, "done");
} catch (error) {
// On failure, release lock so Stripe can retry
await redis.del(idempotencyKey);
throw error;
}
return Response.json({ received: true });
}Common Pitfalls and Fixes
Pitfall 1: Agent execution timeouts
Default Next.js API Routes timeout at 15 seconds. An agent making multiple tool calls blows past this easily.
export const maxDuration = 300; // 5 minutes (Vercel Pro+)
export const runtime = "nodejs"; // Edge Runtime doesn't support timeout extensionOn Vercel Free, the hard cap is 60 seconds. For long-running tasks, use a background job approach (Trigger.dev, QStash, etc.): return a task ID immediately and have the frontend poll for completion.
Pitfall 2: AgentKit rate limits
Gemma 4 API has request rate limits. When multiple users launch agents simultaneously, you'll hit 429 Too Many Requests almost immediately.
// src/lib/agentkit/rate-limiter.ts
export async function acquireAgentSlot(
userId: string,
maxParallel: number
): Promise<{ acquired: boolean }> {
const activeKey = `agent:active:${userId}`;
const currentActive = await redis.incr(activeKey);
await redis.expire(activeKey, 300); // Auto-clear after 5 min (crash guard)
if (currentActive > maxParallel) {
await redis.decr(activeKey);
return { acquired: false };
}
return { acquired: true };
}
export async function releaseAgentSlot(userId: string): Promise<void> {
const current = await redis.get<number>(`agent:active:${userId}`);
if (current && current > 0) {
await redis.decr(`agent:active:${userId}`);
}
}Always call releaseAgentSlot in a finally block. If an error occurs and the slot isn't released, the user's parallel slot count stays permanently blocked.
Pitfall 3: Stripe Price ID environment confusion
Local and production environments use different Price IDs — expected. But accidentally mixing test IDs into production env vars causes confusing errors. Add a startup validation:
// src/lib/stripe/validate.ts
if (process.env.NODE_ENV === "production") {
const key = process.env.STRIPE_SECRET_KEY\!;
if (key.startsWith("sk_test_")) {
throw new Error("Test Stripe key detected in production environment");
}
}Production Deployment
Ask Antigravity to handle the deployment configuration:
Add Vercel deployment configuration for this project.
Required environment variables:
- AGENTKIT_API_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET
- STRIPE_PRO_PRICE_ID, STRIPE_BUSINESS_PRICE_ID
- UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
Create vercel.json and .vercelignore too.
After deploying, register your Webhook endpoint in the Stripe Dashboard (Webhooks → Add endpoint → https://your-domain.com/api/webhook). Only subscribe to events you actually handle — every extra event type means more Redis calls for idempotency checks.
For monitoring, a simple /api/health endpoint with Uptime Robot (free tier) is enough to maintain 99.5%+ uptime for solo-operated services.
Pricing Strategy for Indie Developers
The most common mistake indie developers make is pricing too low. To reach ¥100,000/month at ¥300/month you need 334 paying users. At ¥1,980/month, you need 51.
AI agent services are well-suited to higher price points because:
- The alternative (hiring someone to do the work) costs dramatically more
- Output quality is quantifiable (analysis results, generated content)
- API costs are real and justify a credible price floor
In my own service, Pro plan ARPU sits around ¥2,300/month with roughly 65% gross margin after API costs.
Wrapping Up
The Antigravity × AgentKit 2.0 × Stripe stack is, in my experience, the most practical path for an indie developer going from "just works in a tutorial" to "people pay for this in production."
Three things to get right from the start:
- Use Redis atomically for usage tracking —
pipeline()with Upstash eliminates race conditions - Design Webhooks for idempotency first — assume every event arrives twice
- Deny by default — when plan verification fails, restrict, don't grant
If tutorials left you knowing the concepts but stuck on implementation, the move is simple: get /api/agent/run responding to a real request today. A working endpoint, even rough, is worth more than perfect architecture on paper — because the next real problems (user acquisition, pricing, UX) can only be discovered by shipping.