Building Real-Time Collaborative SaaS with Antigravity and Convex: From Type-Safe Data Sync to Stripe Billing
Build a real-time collaborative SaaS with Antigravity IDE and Convex — schema design, Clerk auth, file storage, Stripe billing, and the webhook implementation that quietly fails on Convex, with the fix.
For indie developers and small teams, shipping a real-time collaborative SaaS has always been a slow project. But the slow part is rarely the syncing itself. Getting edits to flow between tabs takes a few days at most.
When I built document sharing into one of my own products, the flow-between-tabs part worked on day two. The remaining five days went to a single symptom: close a tab, reopen it, and one side's edits are gone. Missed writes on reconnect, merging what happened while offline, subscriptions that never got torn down. That's where the time actually goes.
Convex changes that ratio. It's a reactive backend where type-safe schema definitions, automatic subscription management, and serverless function execution are one system — and you write zero lines of WebSocket code on the client. Pair it with Antigravity IDE and the loop that shortens most visibly is the one after a schema change, when you have to chase every caller that just broke.
What follows builds a real-time collaborative document editor as a SaaS on Convex and Antigravity: Clerk for authentication, Convex's built-in storage for file uploads, Stripe for subscriptions, and a deploy to Cloudflare Pages. The finish line is an app that can actually take money.
There's one detour along the way. The Stripe webhook implementation you'll find in most write-ups — a Next.js Route Handler calling into Convex — does not work on Convex. TypeScript won't tell you. You find out when your Stripe dashboard fills up with 500s. I hit this in production, so I'll put the naive version down first and then take apart the three reasons it fails.
The first run opens your browser to the Convex dashboard for authentication. Once you log in, your project is automatically created and the following environment variable is added to .env.local:
Your schema is the backbone of your SaaS. Ask Antigravity's Agent:
Design a Convex schema for a real-time collaborative document SaaS.
I need the following entities:
- Users (synced from Clerk)
- Workspaces (multiple users can join)
- Documents (belong to a workspace)
- Document operation history (for real-time sync)
- Subscription information
Review what the Agent generates and refine as needed.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Developers stuck on Convex real-time sync can complete their data architecture in a single day with Antigravity's AI assistance
✦Learn the code patterns a production SaaS actually needs — schema design, Clerk auth, file storage, and Stripe billing wired end to end
✦Walk away with the technical skills and design judgment to ship a revenue-generating web app using Convex and Antigravity
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Convex's killer feature is that queries automatically stay in sync in real time. Using useQuery in your React components means that when any data changes on the server, the UI updates immediately — no manual polling, no WebSocket management.
// app/workspace/[id]/page.tsx"use client";import { useQuery } from "convex/react";import { api } from "@/convex/_generated/api";import { Id } from "@/convex/_generated/dataModel";export default function WorkspacePage({ params,}: { params: { id: string };}) { // This query re-runs automatically whenever the data changes const documents = useQuery(api.documents.listByWorkspace, { workspaceId: params.id as Id<"workspaces">, }); if (documents === undefined) { return <div className="animate-pulse">Loading...</div>; } return ( <div className="grid gap-4 p-6"> {documents.map((doc) => ( <DocumentCard key={doc._id} document={doc} /> ))} </div> );}
When User A calls updateContent, User B's screen — open in another tab or browser — updates automatically within milliseconds.
Here's the shape you'll see in most Convex + Stripe write-ups: a Next.js Route Handler receives the webhook and calls a Convex mutation through ConvexHttpClient. This is what I wrote first, too.
It breaks in three places. Two of them TypeScript won't catch, and one only shows up in production. The next section takes them apart one at a time, so read this version as-is for now.
// app/api/stripe/webhook/route.ts — this does not workimport Stripe from "stripe";import { headers } from "next/headers";import { ConvexHttpClient } from "convex/browser";import { api } from "@/convex/_generated/api";const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);export async function POST(req: Request) { const body = await req.text(); const sig = headers().get("stripe-signature")!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); } catch { return new Response("Signature verification failed", { status: 400 }); } switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.CheckoutSession; const clerkId = session.metadata?.clerkId; if (!clerkId) break; await convex.mutation(api.subscriptions.activate, { clerkId, stripeCustomerId: session.customer as string, stripeSubscriptionId: session.subscription as string, }); break; } case "customer.subscription.deleted": { const subscription = event.data.object as Stripe.Subscription; await convex.mutation(api.subscriptions.deactivate, { stripeSubscriptionId: subscription.id, }); break; } } return new Response("OK", { status: 200 });}
Push the previous section's code to production and your Stripe dashboard's webhook log fills with 500s. Running stripe listen locally gives you the same thing. There are three causes, and they fail in three different ways.
Reason 1: internalMutation can't be called from a client
In 6-2 we defined activate and deactivate as internalMutation. Then in 6-1 we called one of them from ConvexHttpClient as api.subscriptions.activate. That combination can't work.
The Convex docs are explicit: "Internal functions can only be called by other functions and cannot be called directly from a Convex client." Internal functions live on the internal object in _generated/api, not on api, and only other Convex functions can reach them — via ctx.runMutation or the scheduler.
That leaves two ways forward.
Option
What it means
Trade-off
A. Make it a public mutation
Change internalMutation to mutation, add a shared secret argument, and verify it yourself
Quick, but it puts a function that rewrites billing state on your public surface. Forget the secret check once and anyone can promote themselves to Pro
B. Receive the webhook in a Convex HTTP Action
Verify the Stripe signature inside Convex, then call ctx.runMutation(internal.…)
Internal functions stay internal, and no billing relay code lingers in your Next.js app
I went with B. Option A stakes your security on remembering to write a check — and billing functions are the last place I want that kind of promise. If a function can stay off the public surface, it should.
Reason 2: Stripe.CheckoutSession isn't a real type
event.data.object as Stripe.CheckoutSession doesn't exist in the stripe package's type definitions. The correct name has an extra namespace: Stripe.Checkout.Session. Since the subscription branch really is Stripe.Subscription, having only one of the two wrong is easy to miss.
// Wrongconst session = event.data.object as Stripe.CheckoutSession;// Rightconst session = event.data.object as Stripe.Checkout.Session;
Reason 3: headers() returns a Promise in Next.js 15 and later
headers().get("stripe-signature") is Next.js 14 syntax. In Next.js 15, headers, cookies, and draftMode became asynchronous and now return promises. Synchronous access still works for the moment for compatibility, but it warns in both development and production. One await fixes it.
const sig = (await headers()).get("stripe-signature")!;
If you're migrating a larger codebase, npx @next/codemod@canary next-async-request-api rewrites these call sites for you.
The fixed version: move it into convex/http.ts
Fixing all three moves the webhook endpoint into Convex itself. Convex registers routes on an httpRouter in convex/http.ts and defines handlers with httpAction. Inside an httpAction you get ctx.runMutation, ctx.runQuery, and ctx.runAction — so internal functions are callable directly.
// convex/http.tsimport { httpRouter } from "convex/server";import { httpAction } from "./_generated/server";import { internal } from "./_generated/api";import Stripe from "stripe";const http = httpRouter();http.route({ path: "/stripe/webhook", method: "POST", handler: httpAction(async (ctx, request) => { const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const body = await request.text(); const sig = request.headers.get("stripe-signature"); if (!sig) return new Response("Missing signature", { status: 400 }); let event: Stripe.Event; try { // Use the async variant on Workers-style runtimes event = await stripe.webhooks.constructEventAsync( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); } catch { return new Response("Signature verification failed", { status: 400 }); } switch (event.type) { case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; const clerkId = session.metadata?.clerkId; if (!clerkId) break; await ctx.runMutation(internal.subscriptions.activate, { clerkId, stripeCustomerId: session.customer as string, stripeSubscriptionId: session.subscription as string, }); break; } case "customer.subscription.deleted": { const subscription = event.data.object as Stripe.Subscription; await ctx.runMutation(internal.subscriptions.deactivate, { stripeSubscriptionId: subscription.id, }); break; } } // Anything other than a 200 and Stripe keeps retrying return new Response(null, { status: 200 }); }),});export default http;
One note on constructEventAsync. Convex HTTP Actions, like Cloudflare Workers, don't run the Node.js crypto module that the synchronous signature check depends on. Leave constructEvent in place and you get the most time-consuming failure mode there is: a correct signature that fails verification anyway.
The other easy one to miss is the endpoint URL you register in the Stripe dashboard. Convex serves HTTP Actions from .convex.site, not .convex.cloud.
Register the convex.cloud host and you get a 404, and Stripe will only tell you the endpoint doesn't exist. That one cost me about an hour.
The internalMutation definitions in 6-2 stay exactly as they are. The real change in this section is that they're reached through internal instead of api — and that app/api/stripe/webhook/route.ts can be deleted.
7. Debugging and Refactoring with Antigravity
7-1. Common Errors Antigravity Helps You Fix
Error 1: Convex query argument type mismatch
Type 'string' is not assignable to type 'Id<"workspaces">'
Paste this into Antigravity's chat and ask it to fix the error. It'll immediately suggest:
// Beforeconst docs = useQuery(api.documents.listByWorkspace, { workspaceId: params.id, // string type — wrong});// Afterimport { Id } from "@/convex/_generated/dataModel";const docs = useQuery(api.documents.listByWorkspace, { workspaceId: params.id as Id<"workspaces">, // correct});
Error 2: Calling external APIs from Convex queries
Convex queries and mutations can't call external APIs directly. Use action instead:
After writing any significant chunk of code, ask Antigravity: "Please review this code for security vulnerabilities and performance issues." You'll get actionable feedback like:
Missing permission checks in Convex mutations
Using collect() where paginate() should be used for large datasets
Queries that aren't using indexes properly
This kind of AI-assisted code review catches production-level bugs before they ever ship.
8. Deploying to Cloudflare Pages
8-1. Configuration
pnpm add -D @cloudflare/next-on-pages
Create wrangler.toml:
name = "collabdocs"compatibility_date = "2024-01-01"compatibility_flags = ["nodejs_compat"][build]command = "pnpm run build"
This deploys your Convex functions and schema to the production environment and gives you a production URL to set in Cloudflare.
9. Enforcing Plan Limits
Gating features by plan creates a natural upgrade path for your users.
// convex/workspaces.tsexport const create = mutation({ args: { name: v.string() }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); if (!user) throw new Error("Authentication required"); // Enforce Free plan limits if (user.plan === "free") { const existingWorkspaces = await ctx.db .query("workspaces") .withIndex("by_owner", (q) => q.eq("ownerId", user._id)) .collect(); if (existingWorkspaces.length >= 3) { throw new Error( "Free plan is limited to 3 workspaces. Upgrade to Pro to create more." ); } } const now = Date.now(); return await ctx.db.insert("workspaces", { name: args.name, ownerId: user._id, plan: user.plan, memberIds: [user._id], createdAt: now, updatedAt: now, }); },});
Notes from building this solo
The steps above are the clean version. Here's what it actually felt like to run this alone.
Antigravity earned its keep somewhere other than writing code. It was the loop after a schema change — reconnecting every caller that just broke. Adding archivedAt to the documents table produced type errors in eleven places across queries, mutations, and React components. Handing the Agent "follow this schema change through" fixed nine of them in one pass. The two it left were the ones where I hadn't decided whether archived documents belonged in that view at all. What remained on my desk was exactly the part that needed a judgment call.
There was also a place where Convex was the wrong choice. I tried to put roughly 300,000 event-log rows in it for analytics. Convex is built around per-document subscriptions, which is a poor fit for scanning large row counts in aggregate. collect() ran into execution limits, and I ended up moving that slice out to a separate analytics store. The mistake was treating "collaborative editing state" and "an append-only log" as the same kind of data.
On billing, I should have spent the time on observability rather than implementation. Each of the three webhook failures takes about five minutes to fix. What consumed the day was not knowing which one I was looking at. Opening convex logs and firing stripe trigger checkout.session.completed against it — as the first step, not the last — would have saved most of that. Building a local reproduction before the code works is obvious advice that I skipped once things started moving.
And plan limits are cheaper to add on day one. The existingWorkspaces.length >= 3 check in section 9 looks like something to defer, but retrofitting it turns into a migration question: what do you do with the user who already has four? Put it in alongside the initial schema.
Wrapping up
What Convex and Antigravity actually shortened wasn't the real-time sync implementation so much as the cost of changing your mind about the design. When a schema stops feeling like something you're locked into, you stop needing to get it perfect on the first pass.
Billing was the exception — the one area where AI assistance didn't help much. Calling an internalMutation through api, mixing up .convex.site and .convex.cloud: both of those read as correct code. That part still comes down to spending real time in the documentation.
If you're picking this up, start with a single convex/http.ts and get stripe trigger checkout.session.completed passing locally before you build out the app. Having that one wire connected first changes how the rest of the project goes.
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.