Install is the easy part: paste a restricted key, click Install - webhooks + backfill configure themselves.
You buy the long tail: 29 tables in your stripe schema - 24 object types - kept in sync, with idempotent webhooks, rate limits and Stripe’s field-moves handled for you.
Your internal user ID goes into the Stripe customer’s metadata at checkout
Emails change or never exist - only 2 of my users have one on file
After that, every billing question is a SQL join away.
Who Signed Up but Never Paid?
Users, account older than a week, no subscription on file:
14 users - pulled live, one query.
handle
signed up
bruno222
2026-06-24
Slack-Lever-4796
2026-06-24
Lean-Saw-7283
2026-06-24
…
11 more
That’s a re-engagement list. Against the Stripe API you’d paginate customers and correlate by hand.
What’s the MRR?
plan
subscribers
mrr
Premium Monthly
9
$45.00
Premium Yearly
4
$13.33
Live result, milliseconds. Yearly normalized to monthly - a naive sum overstates annual plans 12x, and getting that wrong on a dashboard is how you misreport to your own team.
Who’s About to Churn?
Paying users with no product activity in 7 days, by renewal date:
handle
renews
last played
Bluff-Saw-7849
2026-08-29
2026-07-29
Even-Spring-2034
2026-08-29
2026-07-29
Wary-Wedge-2309
2026-08-29
2026-07-26
…
5 more
8 of 13 paying users. The other five played within the week - the metric correctly leaves them off. Dormant by the 7-day window is what the metric shows, not a guarantee they will churn.
Or we can just ask
Once billing is tables, your agent can answer in plain English - via Supabase’s MCP server from your agent/tool of choice
you ask
it runs
you get
“who never converted?”
NOT EXISTS join
14 users
“MRR by plan?”
subscription_items aggregate
$58.33
“who’s churning?”
7-day activity join
8 of 13
One URL: project-scoped, read-only, database tools only. OAuth in the browser - no keys to manage.
“which paying users should I win back this week, and when do they renew?”
The agent inspects the schema, writes the SQL, runs it against the branch, and answers - names and renewal dates
Takeaways
Billing is relational data. Put it where your other relational data lives.
Buy the maintenance, not the install. The webhook is a day’s work; the long tail is what’s managed.
Join on your internal ID in Stripe metadata - never email.
Conversion, MRR, churn become SQL you already know - or a question you hand to an agent.
Appendix: the SQL
Never-converted (slide 11):
select p.handle, p.created_at as signed_upfrom players pwhere p.created_at < now() -interval'7 days'andnotexists (select1from stripe.customers cjoin stripe.subscriptions s on s.customer = c.idwhere c.metadata->>'player_id'= p.id::text )orderby p.created_at;
NOT EXISTS, not LEFT JOIN … IS NULL - players can have multiple Stripe customer rows; only one needs a subscription.
Appendix: MRR + churn SQL
-- MRR by plan: price lives on subscription_items, not the subscription-- (yearly normalized: divide by # of months)select prod.name, count(*),sum(p.unit_amount *casewhen p.recurring->>'interval'='year'then1.0/12else1end) /100.0as mrrfrom stripe.subscriptions sjoin stripe.subscription_items si on si.subscription = s.idjoin stripe.prices p on p.id= si.price #>>'{}'join stripe.products prod on prod.id= p.productwhere s.status ='active'groupby prod.name;
-- at-risk: read the period from subscription_items, not subscriptions-- current_period_end is NULL on subscriptions; Stripe API 2025-03-31.basil moved it to the item.-- It survives in subscription_items._raw_data as a bigint timestamp.havingcoalesce(max(r.started_at), '-infinity'::timestamptz)< now() -interval'7 days'
Appendix: when Stripe’s API changes
When the API moves, you change a query in your own database - not a support ticket, not a vendor’s roadmap.
It already happened on this account. Stripe’s 2025-03-31.basil release moved the renewal date from subscriptions to subscription_items; when the account’s API version caught up, a routine sync rewrote the rows and the old column read NULL. No error, no alert.
The full payload survives: every synced table keeps a _raw_data jsonb column, and the typed columns are projections of it. The fix was one line of SQL reading the new location - subscription_items._raw_data ->> 'current_period_end'. Everything else - customers, invoices, charges, the joins you just watched - never stopped syncing.