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.
| Layer | Choice | Why it works for agent-led builds |
|---|---|---|
| Frontend / API | Next.js (App Router) | UI and API routes live in one repository, so the context the agent needs sits in a single place |
| Database / Auth | Supabase | Authorization is enforced in the database, so application-layer permission bugs do not reach production data |
| Payments | Stripe | Subscription 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 generationThat 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.
| Work | Owner | Reasoning |
|---|---|---|
| UI components and layout | Agent | Mistakes are visible on screen, so review is cheap |
| Supabase type generation and DB client | Agent | Mismatched types fail the build, so a machine catches the error |
| Table definitions and RLS policies | Agent drafts, I decide | A missing policy passes quietly, so the final call stays with me |
| Checkout session creation | Reviewed before use | A dropped metadata field only hurts several events later |
| Webhook handler | Human | Signature verification and idempotency break in production while tests stay green |
| Environment variables and key placement | Human | A 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:
- Forward events locally with
stripe listen --forward-to localhost:3000/api/webhooks/stripe, firestripe trigger customer.subscription.deleted, and confirm thestatuscolumn changes - Replay the same event twice and confirm the second response returns
duplicate: true - Check
STRIPE_WEBHOOK_SECRETagainst the endpoint configuration in the Stripe dashboard, in case test and live secrets got swapped - Attempt an
updateonsubscriptionswith the anon key and confirm RLS rejects it - Grep the build output to confirm
SUPABASE_SERVICE_ROLE_KEYnever appears behind aNEXT_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.