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 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.
- Subscriptions are mutable — upgrades, prorations, cancellations, credit notes — so any honest revenue math needs every invoice for all time, not the most recent page.
- 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.
Three mechanisms, all writing with idempotent upserts keyed on Stripe ids:
- 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.
- Incremental cron (every 5 minutes) —
cron.tspolls 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. - Re-sync — the "Queue full re-sync" button on
/adminqueues 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.
-
Get a Stripe API key — use this link to create a key with reqiuired read-only permissions (Customers, Products, Prices, Subscriptions, Invoices, Credit Notes, Events)
-
Paste it as an env var in this val - use this link to add
STRIPE_API_KEY -
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.
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):
| Table | Source |
|---|---|
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;
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.
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;
cron.ts— 5-minute interval: advances a pending backfill, otherwise events syncindex.tsx— the site: landing page, /admin status + controls, /data browserconfig.ts— tables, rate limits, tuning knobslib/stripe.ts— rate-limited Stripe client (token bucket + concurrency gate + 429/5xx retry)lib/backfill.ts,lib/events.ts— the two sync engineslib/schema.ts,lib/rows.ts,lib/state.ts,lib/db.ts— tables, object→row mapping, cursors, upsert helperlib/metrics.ts— MRR/ARR and status querieslib/arr.ts,lib/chart.tsx— the monthly ARR series and the SVG chartlib/landing.tsx,lib/admin.tsx,lib/browse.tsx,lib/views.tsx— the three pages and shared layout
- Stripe only retains 30 days of events. If the cron is ever paused longer
than that, queue a re-sync from
/adminto 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 inlib/backfill.ts, a row mapper inlib/rows.ts, a table inlib/schema.ts, and (for live updates) a case inlib/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_FRACTIONif 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.