ANTIGRAVITY LABJP
Articles/Agents & Manager
Agents & Manager/2026-04-14Advanced

Build an AI Agent SaaS with Antigravity × AgentKit 2.0 × Stripe — A Complete Implementation Guide for Solo Developers

A complete guide to building a subscription-based AI agent SaaS using AgentKit 2.0 and Stripe with Antigravity. Covers architecture design, billing integration, rate limiting, Webhook idempotency, and production deployment — everything tutorials skip.

AgentKit 2.013Antigravity321Stripe14SaaS10indie dev17AI agent5multi-agent49

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/redis

Upstash 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 extension

On 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:

  1. Use Redis atomically for usage trackingpipeline() with Upstash eliminates race conditions
  2. Design Webhooks for idempotency first — assume every event arrives twice
  3. 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.

Share

Thank You for Reading

Antigravity 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

Agents & Manager2026-05-05
Building a Subscription AI Agent Service with AgentKit 2.0 — Stripe Billing to Monthly Revenue Design
Complete guide to building and monetizing a subscription-based AI agent service using AgentKit 2.0. Covers Stripe integration, multi-agent design, pricing strategy, and churn prevention — everything needed to reach stable monthly recurring revenue.
Agents & Manager2026-05-03
Building an Agent-Driven SaaS on Antigravity and Stripe — A Complete 2026 Implementation Guide
An end-to-end implementation guide for shipping an automation SaaS built on Antigravity's agent capabilities, Stripe, and Cloudflare Workers — covering task queuing, concurrency control, webhook handling, and the agent-specific edge cases (timeouts, partial failure, cost runaway) that always show up.
Agents & Manager2026-04-27
Antigravity Agent Product Launch Blueprint — A 90-Day Roadmap to Ship and Sell an AgentKit 2.0 Product as an Indie Developer
A complete 90-day roadmap for turning an AgentKit 2.0 build into a product an indie developer can actually sell — covering product design, production-grade implementation, distribution channel choice, Stripe integration, launch prep, and the operational discipline that decides whether an agent business survives.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →