Stripe Sync Engine
Your Stripe data, in your Postgres
Billing Data and Product Data Don’t Talk
Your billing data lives in Stripe. Your product data lives in Postgres.
“Which customers are on which plan?”
“What features do paying users actually use?”
“Which accounts are about to churn?”
Today, two bad options:
API calls on demand - fine until you join, aggregate, or run analytics
Build a sync pipeline - pagination, webhooks, rate limits, retries. Days to weeks.
Open on the pain, not the product. Everyone in this room has written this glue code or felt the pain of not having it. The three questions are deliberately founder-language: conversion, engagement, churn. Hold for nods.
FDW, Hand-Rolled, or Sync Engine
Stripe FDW
seconds per query
painful
yes
Hand-rolled pipeline
good
good
your problem
Stripe Sync Engine
milliseconds
native SQL
none
Copy the data into real Postgres tables. Query it like everything else.
One-click install from the Supabase Dashboard
Built and maintained jointly by Stripe + Supabase
Open source (Apache 2.0)
The FDW row is the key contrast: it translates SQL into live API calls - a phone call to Stripe per query. Fine for “is customer X subscribed?”, terrible for analytics. The sync engine is a local copy. Don’t dwell on hand-rolled - everyone already knows that pain.
How It Works
Sync
stripe
Stripe
q
pgmq
queues
stripe->q
backfill
ef
Edge
Functions
stripe->ef
webhooks
q->ef
db
stripe
schema
ef->db
yt
your tables
players · runs
db->yt
SQL joins
Install: paste a restricted key, click Install. Webhooks + backfill configure themselves.
23 object types: customers, subscriptions, invoices, charges, products, prices, coupons…
JSONB storage + generated columns: flexible, but indexed and queryable.
Walk left to right. Two ingest paths: webhooks for live events, pgmq-driven backfill for history, both landing in a managed stripe schema. Emphasize: no code written, no infra run. The install click is the live beat next.
The Setup Is Real
Everything you’ll see runs against bonkled - a real game:
Realtime versus Wordle roguelike, live at bonkled.erfi.io
Running on Supabase in ap-southeast-1 - this region
55 real players, 29 real runs in the last month
Stripe side: test-mode account, seeded with plans + customers
The product data is real. The billing data is test mode - as it should be for a demo.
Credibility slide. This is not a synthetic demo project: it’s my actual product, running in this region, with real players. The only synthetic part is the Stripe account - and nobody should demo against live billing anyway. Say that line out loud; it lands.
Join on Internal IDs, Not Email
Your internal ID, in Stripe metadata:
select p.handle, c.id as stripe_customer_id
from stripe.customers c
join players p
on p.id ::text = c.metadata->> 'player_id' ;
Why metadata, not email:
Emails change, go missing, or never exist (anonymous sign-ins)
Internal ID in metadata is the pattern to copy into your own apps
Write player_id at checkout time; the database does the rest
This is the one teaching slide. The canonical examples join on email - bonkled’s own user base proves why that’s fragile: 2 of our 55 users have emails at all. Internal-ID-in-metadata is what we’d recommend to anyone. Founders should steal this pattern.
Query 1: Signed Up, Never Converted
select p.handle, p.created_at as signed_up,
now() - p.created_at as days_since_signup
from players p
where p.created_at < now() - interval '7 days'
and not exists (
select 1 from stripe.customers c
join stripe.subscriptions s on s.customer = c.id
where c.metadata->> 'player_id' = p.id ::text )
order by p.created_at;
Live result: 39 players - a re-engagement list, in one query.
Founder framing: “who do I email?” This join is impractical against the Stripe API - you’d paginate customers and correlate by hand. Against local tables it’s a millisecond query. Note the NOT EXISTS: players can have multiple Stripe customer rows; don’t let that corrupt the answer.
Query 2: MRR by Plan
select prod.name as plan , count (* ) as subscribers,
sum (p.unit_amount
* case when p.recurring->> 'interval' = 'year'
then 1.0 / 12 else 1 end ) / 100.0 as mrr
from stripe.subscriptions s
join stripe.subscription_items si on si.subscription = s.id
join stripe.prices p on p.id = si.price #>> '{}'
join stripe.products prod on prod.id = p.product
where s.status = 'active'
group by prod.name order by mrr desc ;
Bonkled Premium Monthly
9
45.00
Bonkled Premium Yearly
4
13.33
“What am I earning?” Point out the yearly normalization - a naive sum overstates annual plans 12x. This query runs in milliseconds regardless of subscription count because the data is local and indexed.
Query 3: Who’s About to Churn
select p.handle,
to_timestamp (s.current_period_end) as renewal_date,
max (r.started_at) as last_run
from stripe.customers c
join players p on p.id ::text = c.metadata->> 'player_id'
join stripe.subscriptions s
on s.customer = c.id and s.status = 'active'
left join runs r on r.player_id = p.id
group by p.handle, s.current_period_end
having coalesce (max (r.started_at), '-infinity' ::timestamptz )
< now() - interval '7 days' ;
Live result: 12 of 13 paying players - billing status + gameplay, one SELECT.
The money slide. This query is the entire pitch in one statement: it joins billing state (Stripe) against product usage (gameplay runs) - the two data silos from slide 1, unified. The one player NOT on the list played two days ago; the window is honest. Founder framing: “this is your win-back list, ranked by renewal date.”
Under the Hood
What the install actually did to the project:
stripe schema: 29 tables - JSONB _raw_data + generated columns
Managed Edge Functions processing webhooks (source viewable in-dashboard)
pgmq queues + cron driving incremental backfill with retries
Seed objects landed seconds after creation - webhooks, not polling
Verified live: 15 customers, 15 subscriptions, charges, invoices, payment intents - all queryable immediately.
For the technical founders who stayed skeptical through the queries. No magic: it’s Edge Functions + queues + a schema you can inspect. One-click upgrades, branch installs for safe dev workflows. Everything on this slide was verified against the actual install today, not read from docs.
Where It’s Going
The engine is being rebuilt as a general pipeline :
Airbyte-style source/destination protocol (NDJSON over stdio)
source-stripe fully decoupled from destinations
Postgres today; Google Sheets destination already implemented
Active development: stripe/sync-engine
Your billing data, synced anywhere - not just Postgres.
Forward-looking close for the “what’s next” question. The Stripe-to-Sheets beat gets a laugh from anyone whose CFO asks for a spreadsheet - keep it light. The core message: this is an actively invested, jointly owned integration, not a side project.
Takeaways
Billing is relational data. Put it where your other relational data lives.
One click, not one pipeline. Webhooks + backfill, managed.
Join on your internal ID in Stripe metadata - never email.
The questions that matter - conversion, MRR, churn - become SQL you already know .
Land the plane. Four points, each maps to a slide. Then: “I clicked one button and my Stripe data became tables - the queries you just saw took minutes to write, not weeks of pipeline.” Invite booth follow-ups.