Public
Mirror your Stripe account into SQLite to power ARR/MRR charts
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

stripe-mirror

Your Stripe account, mirrored into a SQLite database you can query — built to power ARR charts like the ones in Val Town's investor updates, and any other revenue question you'd rather answer with SQL than with API calls.

The problem this solves

  1. The Stripe API is rate limited, so getting all of your data out is the hard part. The mirror backfills everything once — in parallel, just under the limit — then stays in sync from the Events API.
  2. Subscriptions are mutable — upgrades, prorations, cancellations, credit notes — so any honest revenue math needs every invoice for all time, not the most recent page.
  3. It's free and fully yours. Unlike Stripe Sigma or Data Pipeline, there's nothing to pay for and nothing you can't change: it's a few hundred lines of TypeScript in your own val, same model as Supabase's Stripe Sync Engine.

How it works

Three mechanisms, all writing with idempotent upserts keyed on Stripe ids:

  1. Backfill — walks every enabled resource's list endpoint through all of history. All resources run in parallel and child fetches fan out further; a shared token bucket holds total throughput at 80% of Stripe's rate limit (100 reads/sec live, 25/sec test), so years of data land in minutes without starving your production traffic. Progress is checkpointed page by page, so it resumes cleanly across runs.
  2. Incremental cron (every 5 minutes)cron.ts polls the Events API for what changed, refetches those objects from Stripe (the event payload is never trusted as the source of truth), and upserts just those rows. A typical run touches zero or a handful of objects.
  3. Re-sync — the "Queue full re-sync" button on /admin queues a fresh full walk at any time. It upserts over existing rows, so the mirror stays queryable while it runs.

The event cursor starts when the schema is first created, so anything that changes during the backfill is replayed by the cron afterward — no gap between the two.

Setup

  1. Get a Stripe API keyuse this link to create a key with reqiuired read-only permissions (Customers, Products, Prices, Subscriptions, Invoices, Credit Notes, Events)

  2. Paste it as an env var in this val - use this link to add STRIPE_API_KEY

  3. Open your val's site and go to /admin — click Run backfill slice, or do nothing; the cron advances the backfill automatically in 5-minute slices.

The site

index.tsx serves three pages (server-rendered Hono JSX, no client JS):

  • / — a public landing page with a live ARR chart, month-over-month growth labels included
  • /admin — MRR/ARR, MRR by product, sync job status, and buttons for "Sync now" and "Queue full re-sync"
  • /data — a read-only browser for the mirrored tables: page through rows, click an id to see the complete raw Stripe object

/admin and /data show revenue numbers and customer data, so set a password to put them behind basic auth (username admin; the landing page stays public):

👉 Add DASHBOARD_PASSWORD here

Tables

TableSource
stripe_customers/v1/customers
stripe_products/v1/products
stripe_prices/v1/prices
stripe_subscriptions + stripe_subscription_items/v1/subscriptions?status=all
stripe_invoices + stripe_invoice_lines/v1/invoices
stripe_credit_notes/v1/credit_notes

Each table promotes a few columns for joins and indexing, and keeps the complete Stripe object as JSON in raw. Anything not promoted is one json_extract away:

SELECT json_extract(raw, '$.metadata.plan_tier') AS tier, COUNT(*) FROM stripe_subscriptions WHERE status = 'active' GROUP BY tier;

Configuring what syncs

Everything tunable lives in config.ts: which tables to mirror, the rate-limit fraction, concurrency, and retention. Turn a resource off and the sync leaves it alone; turn a new one on and the next cron run backfills it from the beginning, automatically.

Example queries

Monthly recognized revenue (invoice line periods, prorations included):

SELECT strftime('%Y-%m', period_start, 'unixepoch') AS month, SUM(amount) / 100.0 AS revenue FROM stripe_invoice_lines l JOIN stripe_invoices i ON i.id = l.invoice_id WHERE i.status = 'paid' GROUP BY month ORDER BY month;

Active MRR by product:

SELECT pr.name, SUM(p.unit_amount * si.quantity) / 100.0 AS mrr FROM stripe_subscription_items si JOIN stripe_subscriptions s ON s.id = si.subscription_id JOIN stripe_prices p ON p.id = si.price_id JOIN stripe_products pr ON pr.id = p.product_id WHERE s.status = 'active' AND p.recurring_interval = 'month' GROUP BY pr.name ORDER BY mrr DESC;

Files

  • cron.ts — 5-minute interval: advances a pending backfill, otherwise events sync
  • index.tsx — the site: landing page, /admin status + controls, /data browser
  • config.ts — tables, rate limits, tuning knobs
  • lib/stripe.ts — rate-limited Stripe client (token bucket + concurrency gate + 429/5xx retry)
  • lib/backfill.ts, lib/events.ts — the two sync engines
  • lib/schema.ts, lib/rows.ts, lib/state.ts, lib/db.ts — tables, object→row mapping, cursors, upsert helper
  • lib/metrics.ts — MRR/ARR and status queries
  • lib/arr.ts, lib/chart.tsx — the monthly ARR series and the SVG chart
  • lib/landing.tsx, lib/admin.tsx, lib/browse.tsx, lib/views.tsx — the three pages and shared layout

Limits and notes

  • Stripe only retains 30 days of events. If the cron is ever paused longer than that, queue a re-sync from /admin to catch up.
  • Deletions arrive via events (the refetch 404s and the row is dropped). Objects deleted while the cron was paused past retention are only cleaned up by a re-sync.
  • Charges, payment intents, refunds, and disputes aren't mirrored by default. To add a resource: a toggle in config.ts, a list entry in lib/backfill.ts, a row mapper in lib/rows.ts, a table in lib/schema.ts, and (for live updates) a case in lib/events.ts.
  • The rate limiter is per-process: a cron run and a "Sync now" click can briefly overlap (harmless — every write is an upsert, you just exceed the 80% budget for a moment). Lower RATE_LIMIT_FRACTION if other systems share the same Stripe key's budget.
  • Sandbox quirk: objects attached to test clocks don't appear in Stripe's list endpoints, so the backfill can't see them — they arrive via the events sync instead. Production data is unaffected.