◉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

How to build a subscription AI agent SaaS with AgentKit 2.0, Stripe and Antigravity. Architecture, billing, Webhook idempotency, production deployment — plus the Redis concurrency-slot bug that quietly locks out your heaviest users.

AgentKit 2.013Antigravity377Stripe14SaaS10indie dev19AI agent5multi-agent50

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";
import { acquireAgentSlot, releaseAgentSlot } from "@/lib/agentkit/rate-limiter";
 
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 }
    );
  }
 
  // 4. Reserve a concurrency slot (released in the finally block below;
  //    the implementation is in Pitfall 2)
  const slot = await acquireAgentSlot(userId, planConfig.maxParallelAgents);
  if (!slot.acquired) {
    return Response.json(
      {
        error: "Concurrent execution limit reached",
        queuePosition: slot.queuePosition,
      },
      { status: 429 }
    );
  }
 
  const { prompt, tools: requestedTools } = await req.json();
 
  // 5. 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 {
      // Release before closing the writer — skipping this leaves the slot occupied
      await releaseAgentSlot(userId);
      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
import { Redis } from "@upstash/redis";
 
const redis = Redis.fromEnv();
const SLOT_TTL_SECONDS = 300;
 
// Acquire: INCR / EXPIRE / DECR folded into one script.
// The TTL is set only when the key is first created.
const ACQUIRE_SCRIPT = `
local active = redis.call('INCR', KEYS[1])
if active == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if active > tonumber(ARGV[1]) then
  redis.call('DECR', KEYS[1])
  return active - tonumber(ARGV[1])
end
return 0
`;
 
// Release: if the counter goes negative, reset it to 0 with KEEPTTL
// so the expiry survives.
const RELEASE_SCRIPT = `
local active = redis.call('DECR', KEYS[1])
if active < 0 then
  redis.call('SET', KEYS[1], 0, 'KEEPTTL')
end
return 1
`;
 
export async function acquireAgentSlot(
  userId: string,
  maxParallel: number
): Promise<{ acquired: boolean; queuePosition?: number }> {
  const overflow = await redis.eval<string[], number>(
    ACQUIRE_SCRIPT,
    [`agent:active:${userId}`],
    [String(maxParallel), String(SLOT_TTL_SECONDS)]
  );
 
  return overflow === 0
    ? { acquired: true }
    : { acquired: false, queuePosition: overflow };
}
 
export async function releaseAgentSlot(userId: string): Promise<void> {
  await redis.eval(RELEASE_SCRIPT, [`agent:active:${userId}`], []);
}

In the execution route above, the slot is acquired before the TransformStream is built and released inside finally. Reverse that order and an error path closes the response while the slot stays held.

Why this belongs in a Lua script

The obvious version calls redis.incr() and then redis.expire(). That is how I wrote it first. Three things go wrong with the two-command pair once real traffic arrives.

First, if the worker dies between the two calls, you are left with a key that has no TTL at all. The five-minute "crash guard" fails in precisely the situation it exists for.

Second, calling expire on every request refreshes the TTL. As long as traffic keeps landing inside the window, the key never expires, so a leaked count is never reclaimed. Your heaviest users are the ones who lose slots — the quiet accounts are the only ones that self-heal.

Third, the get-then-decr release is two round trips. If another request decrements in between, the counter drifts negative and every later limit check is off by that amount, permanently.

The Lua version closes all three. The TTL is set only when active == 1, so it is never extended. The increment and the overflow rollback happen inside a single script, leaving no intermediate state to crash on. Release repairs a negative counter with KEEPTTL, fixing the value without discarding the expiry.

Upstash's REST client forwards EVAL straight to the server, so this is still one round trip from an Edge runtime. The code reads a little stiffer, but keeping the round-trip count flat while closing the race is a good trade when Redis sits behind an edge call.

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 tracking — pipeline() 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 $15 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-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.
◈ Agents & Manager2026-04-27
Five Paths to Sell AI Agents Built on AgentKit 2.0 — How Indie Developers Should Pick a Distribution Channel
AgentKit 2.0 lets a solo developer ship a real, useful AI agent. The hard question is where to sell it. This article walks through five distribution paths — marketplaces, direct sales, SaaS embedding, productized consulting, and subscription agents — with the trade-offs that matter.
◈ Agents & Manager2026-03-19
Wiring Next.js + Supabase + Stripe Subscriptions with Antigravity — What I Delegated and What I Wrote by Hand
A working record of letting Antigravity agents build the Next.js and Supabase foundation while writing the Stripe Checkout and webhook layer by hand. Covers signature verification, idempotency, and the metadata handoff that quietly breaks generated payment code.
📚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