Stripe Sync Engine

Your Stripe data, in your Postgres

Erfi Anugrah

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:

  1. API calls on demand - fine until you join, aggregate, or run analytics
  2. Build a sync pipeline - pagination, webhooks, rate limits, retries. Days to weeks.

FDW, Hand-Rolled, or Sync Engine

Approach Latency Joins Rate limits
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)

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.

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.

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

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.

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;
plan subscribers mrr
Bonkled Premium Monthly 9 45.00
Bonkled Premium Yearly 4 13.33

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.

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.

Sources: repo transfer + recent capabilities · live validation on a Supabase preview branch, 2026-07

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.

Takeaways

  1. Billing is relational data. Put it where your other relational data lives.
  2. One click, not one pipeline. Webhooks + backfill, managed.
  3. Join on your internal ID in Stripe metadata - never email.
  4. The questions that matter - conversion, MRR, churn - become SQL you already know.