◉ANTIGRAVITY LABJP
Articles/Agents & Manager
◈ Agents & Manager/2026-03-19Advanced

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.

Antigravity377SaaS10Stripe14Next.js6SupabaseWebhookSubscriptionsIndie Development8

The night only billing refused to work

The foundation came together in half a day. Auth screens, dashboard, settings pages — each prompt to the Antigravity agent turned into something that ran. Then I reached payments, and every webhook Stripe sent in test mode came back 400.

The cause was simple. The generated handler parsed the JSON body without ever reading the stripe-signature header. The tests passed. It worked locally against hand-rolled payloads. Only real requests from Stripe failed — which is the worst shape a failure can take.

Since then I keep a hard line through this project. Screens, types, and tests go to the agent. Anything that moves money, I write myself. Here is the reasoning behind that line, and the implementation I actually run.

Why Next.js + Supabase + Stripe

In agent-led development, how well-documented a stack is translates directly into output quality. The more heavily represented a framework is in public material, the closer the first draft lands to something shippable.

LayerChoiceWhy it works for agent-led builds
Frontend / APINext.js (App Router)UI and API routes live in one repository, so the context the agent needs sits in a single place
Database / AuthSupabaseAuthorization is enforced in the database, so application-layer permission bugs do not reach production data
PaymentsStripeSubscription state transitions are handled on Stripe's side; your job is reduced to mirroring them

Mixing in a house-built library or a framework with thin public coverage has the opposite effect. You end up reading every line the agent produces, which erases the speed you came for. Choosing boring, well-trodden infrastructure is itself a performance decision.

Make it write the design first, and read it before any code

Ask for an implementation straight away and you get a large volume of code built on assumptions that quietly disagree with each other. I always request the design document alone, read it, and only then move to implementation.

Antigravity 2.11.0 added @path/to/file references inside AGENTS.md and custom rule files. Rules no longer need to pile up in one enormous file — you can split them by concern and pull them in.

# AGENTS.md
 
## Project assumptions
@docs/architecture.md
@docs/db-schema.md
 
## Payment rules
@docs/rules/payment.md
 
## Generation constraints
- API routes belong in app/api/**/route.ts
- Server-side Supabase access uses the service role key
- app/api/webhooks/ is excluded from generation

That last line carries most of the weight. Declaring the webhook directory off-limits stops the agent from reaching into it on every subsequent task. Putting forbidden territory in the rule file, rather than correcting the agent after the fact, was the most stable arrangement I found.

Where subscription state lives

The truth about a subscription lives at Stripe. Your database is a mirror of it, nothing more. Lose sight of that and cancelled users keep their paid features.

create table public.subscriptions (
  user_id                uuid primary key references auth.users (id) on delete cascade,
  stripe_customer_id     text        not null,
  stripe_subscription_id text        not null unique,
  price_id               text        not null,
  status                 text        not null,
  current_period_end     timestamptz not null,
  cancel_at_period_end   boolean     not null default false,
  updated_at             timestamptz not null default now()
);
 
alter table public.subscriptions enable row level security;
 
-- Read access for the owner only. No write policy, deliberately.
create policy "read own subscription"
  on public.subscriptions
  for select
  using (auth.uid() = user_id);

The absence of a write policy is the point. Once RLS is enabled, any operation without a matching policy is denied by default. That leaves exactly one thing able to update this table: the service role key, which only the webhook handler holds. No amount of client-side tampering can change a billing record.

Entitlement checks look at the period end, not just the status string:

create or replace function public.has_active_subscription(uid uuid)
returns boolean
language sql
stable
as $$
  select exists (
    select 1
      from public.subscriptions
     where user_id = uid
       and status in ('active', 'trialing')
       and current_period_end > now()
  );
$$;

Whether to treat past_due as entitled is a business call rather than a technical one. I wanted a few days of grace after a failed charge, so I let Stripe's Smart Retries and the remaining current_period_end absorb it instead of adding another branch. Every extra condition here is one more thing you cannot reason about six months later.

Checkout: the metadata that later events depend on

Creating a Checkout session is straightforward, with exactly one trap that surfaces much later.

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: NextRequest) {
  const { userId, priceId, customerId } = await req.json();
  const origin = req.headers.get("origin") ?? process.env.NEXT_PUBLIC_SITE_URL!;
 
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${origin}/account?checkout=success`,
    cancel_url: `${origin}/pricing`,
    client_reference_id: userId,
    metadata: { user_id: userId },
    // Omit this and customer.subscription.* events arrive with no user_id
    subscription_data: {
      metadata: { user_id: userId },
    },
  });
 
  return NextResponse.json({ url: session.url });
}

Session metadata stays on the session. It is not inherited by the subscription created from it. Without subscription_data.metadata, you can still resolve the user on checkout.session.completed, but every later customer.subscription.updated or deleted arrives with no way to identify whose subscription it is.

Digging into that first webhook failure, I noticed how often generated code drops this line. The initial purchase succeeds; only cancellations fail to propagate. It is the classic shape of a bug that stays green in tests and breaks in production. Setting it in both places means any event resolves through the same key.

The webhook: signature verification and idempotency, by hand

This is the core of the whole thing. In the App Router, req.text() gives you the raw request body. A payload read with req.json() and re-serialized will not verify — the bytes no longer match.

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@supabase/supabase-js";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
const admin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  { auth: { persistSession: false } }
);
 
export async function POST(req: NextRequest) {
  const signature = req.headers.get("stripe-signature");
  if (!signature) {
    return NextResponse.json({ error: "missing signature" }, { status: 400 });
  }
 
  // Pass the raw body through. Reading it as json() breaks verification.
  const rawBody = await req.text();
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("[stripe] signature verification failed", err);
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }
 
  // Use a primary key collision as the "already handled" signal
  const { error: claimError } = await admin
    .from("processed_stripe_events")
    .insert({ event_id: event.id, type: event.type });
 
  if (claimError) {
    if (claimError.code === "23505") {
      return NextResponse.json({ received: true, duplicate: true });
    }
    console.error("[stripe] claim failed", claimError);
    return NextResponse.json({ error: "claim failed" }, { status: 500 });
  }
 
  try {
    await applyEvent(event);
  } catch (err) {
    // Release the claim so Stripe's retry can reprocess it
    await admin
      .from("processed_stripe_events")
      .delete()
      .eq("event_id", event.id);
    console.error("[stripe] handler failed", event.type, err);
    return NextResponse.json({ error: "handler failed" }, { status: 500 });
  }
 
  return NextResponse.json({ received: true });
}

The idempotency table needs nothing beyond a primary key:

create table public.processed_stripe_events (
  event_id    text primary key,
  type        text        not null,
  received_at timestamptz not null default now()
);

Stripe resends an event until it receives a 2xx. If your response is lost to a network hiccup, the same event.id comes back. Treating a primary key collision (PostgreSQL error code 23505) as "already processed" gives you duplicate protection without holding a lock anywhere in the application.

The apply step overwrites state rather than adjusting it:

async function applyEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      if (!session.subscription) return;
      const sub = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
      await upsert(sub);
      return;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      await upsert(event.data.object as Stripe.Subscription);
      return;
    }
    default:
      return;
  }
}
 
async function upsert(sub: Stripe.Subscription) {
  const userId = sub.metadata?.user_id;
  if (!userId) throw new Error(`missing user_id on ${sub.id}`);
 
  const { error } = await admin.from("subscriptions").upsert(
    {
      user_id: userId,
      stripe_customer_id: sub.customer as string,
      stripe_subscription_id: sub.id,
      price_id: sub.items.data[0]?.price.id ?? "",
      status: sub.status,
      current_period_end: new Date(
        sub.items.data[0].current_period_end * 1000
      ).toISOString(),
      cancel_at_period_end: sub.cancel_at_period_end,
      updated_at: new Date().toISOString(),
    },
    { onConflict: "user_id" }
  );
 
  if (error) throw error;
}

Event ordering is not guaranteed. An implementation that applies deltas will corrupt its own state the first time two events arrive out of order. Overwriting with the current value of the subscription the event points at removes the ordering dependency entirely. Throwing when user_id is missing is also deliberate — returning a 500 and letting Stripe retry is far better than swallowing the case and silently losing the record.

What I delegated, and what I kept

After about ten days the line was clear. Working as an indie developer means there is no second pair of eyes to fall back on, so the split is decided by how much of the output I can stop re-reading — not by how much an agent can produce.

WorkOwnerReasoning
UI components and layoutAgentMistakes are visible on screen, so review is cheap
Supabase type generation and DB clientAgentMismatched types fail the build, so a machine catches the error
Table definitions and RLS policiesAgent drafts, I decideA missing policy passes quietly, so the final call stays with me
Checkout session creationReviewed before useA dropped metadata field only hurts several events later
Webhook handlerHumanSignature verification and idempotency break in production while tests stay green
Environment variables and key placementHumanA service role key leaked to the client cannot be walked back

The deciding question is whether a machine can notice the mistake. Anything that surfaces as a type error or a failing test can be handed over without hesitation. Anything that passes while looking successful — a missing signature check being the clearest case — has to be read by a person.

The pre-deploy pass

Before every deploy I run the same sequence:

  1. Forward events locally with stripe listen --forward-to localhost:3000/api/webhooks/stripe, fire stripe trigger customer.subscription.deleted, and confirm the status column changes
  2. Replay the same event twice and confirm the second response returns duplicate: true
  3. Check STRIPE_WEBHOOK_SECRET against the endpoint configuration in the Stripe dashboard, in case test and live secrets got swapped
  4. Attempt an update on subscriptions with the anon key and confirm RLS rejects it
  5. Grep the build output to confirm SUPABASE_SERVICE_ROLE_KEY never appears behind a NEXT_PUBLIC_ prefix

Steps 2 and 4 were absent from the tests the agent wrote. Generated tests only walk the paths their author imagined. Verifying what sits outside that imagination is work you have to add yourself.

For the Supabase side of the setup, see Antigravity and Supabase in production — Auth, RLS, Edge Functions, and Realtime.

Deciding in advance where to let go

What changed with agents in the loop was less about typing speed and more about clarity. When I wrote everything myself, attention was spread thin and evenly. Handing off the foundation let me concentrate it on the payment path and the data boundary.

Next time I build this shape, the forbidden-territory section of AGENTS.md goes in first. Drawing the line before generation starts is considerably cheaper than intervening once it has.

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-14
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.
◈ 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-09-09
When an Antigravity Scheduled Task Never Runs: Check sidecar.json, Then enabled, Then the Logs
A checking order for Antigravity 2.0 Scheduled Tasks that quietly do nothing: where sidecar.json lives, how the directory name becomes the ID, the enabled flag in config.json, and the logs under sidecar_data.
📚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