quyumkehinde/driftless
Your Postgres never drifts from Stripe. One binary: verified webhook ingest, missed-event detection, resumable backfill, and a reconciliation command that proves your DB matches Stripe. - quyumkehi...
3 stars · 0 forks
README
Driftless Your Postgres never drifts from Stripe. Driftless is a single-binary Go service that receives Stripe webhooks, verifies and deduplicates them, detects the events Stripe never delivered, backfills history through the REST API, and materializes everything into a stripe schema in your own Postgres, with a reconciliation command that proves your database matches Stripe. It is the supported, hardened answer to a problem every Stripe integrator eventually hand-rolls: webhook delivery is at-least-once, unordered, and silently droppable, and the event log only goes back 30 days. Existing tools cover fragments of the problem; Driftless owns the whole receiver side. Driftless is strictly read-only against Stripe: it never writes to your Stripe account, and works with a restricted read-only API key (driftless doctor lists the exact scopes to grant). Quickstart Ten minutes from zero to a proven-correct mirror. You need Postgres 17+, a Stripe API key (a restricted read-only rk_... key is recommended), and a webhook signing secret.
1. Install: one static binary, no runtime
https://github.com/quyumkehinde/driftless/releases
tar xzf driftless_*.tar.gz && sudo install driftless /usr/local/bin/2. Point it at your Postgres and your Stripe account
export DRIFTLESS_DATABASE_URL='postgres://...' export DRIFTLESS_STRIPE_API_KEY='rk_test_...' export DRIFTLESS_STRIPE_WEBHOOK_SECRET='whsec_...'3. First-run setup: environment checks, migrations, offer of a backfill
driftless init4. Run it, and point a Stripe webhook endpoint at your host
https://<your-host>/webhooks/stripe
(dashboard -> Developers -> Webhooks; subscribe to the event types
you mirror, or "all events"; unknown types are stored, never lost)
driftless serve5. Prove it
driftless verify --full Prefer containers? A Docker Compose quickstart, a distroless image at ghcr.io/quyumkehinde/driftless, and Kubernetes manifests ship in deploy/. verify exits 0 when your database provably matches Stripe, and 3 when it found drift, with a per-object report. From then on, serve keeps the mirror correct on its own: webhooks apply in seconds, a sweeper finds anything Stripe generated but never delivered, and a nightly verification runs by default. Using the mirror Day to day, the mirror replaces two things in your application: reads that used to be Stripe API calls, and reactions that used to be webhook handlers. Reads are SQL. Every table carries typed, indexed columns for the hot fields and the full raw JSON alongside (data), so nothing Stripe sends is truncated away. Anywhere you call the Stripe API or maintain hand-rolled billing state, query the tables instead, and join them against your own: -- can this user access the product? SELECT s.status IN ('active', 'trialing') FROM stripe.subscriptions s JOIN app.users u ON u.stripe_customer_id = s.customer WHERE u.id = $1 AND NOT s.is_deleted ORDER BY s.created DESC LIMIT 1; Reactions are LISTEN/NOTIFY. Every applied change fires pg_notify on the driftless_changes channel, in the same transaction as the write, with a minimal payload: {"type": "subscription", "id": "sub_123"}. Your worker listens, re-reads the row, and acts. By the time you are notified, the state is already committed, deduplicated, and ordered: no payload parsing, no signature checks, no duplicate or out-of-order handling in your code. The notification is a doorbell, not the mail: it carries ids only, and it is not durable, so a listener that was down misses the ring but never the row. The pattern below (catch-up scan on every connect, then listen) is complete on its own:Go (pgx) conn, _ := pgx.Connect(ctx, dbURL) // dedicated connection, not a pool conn.Exec(ctx, "LISTEN driftless_changes")
// catch up on anything missed while nobody was listening rows, _ := conn.Query(ctx, "SELECT 'subscription', id FROM stripe.subscriptions WHERE updated_at > $1", since) // handle each row, then:
for { n, _ := conn.WaitForNotification(ctx) // {"type":"subscription","id":"sub_123"} // read the row, act }
Node (pg) const client = new pg.Client({ connectionString: dbURL }); // dedicated, not a pool await client.connect(); await client.query("LISTEN driftless_changes");
// catch up on anything missed while nobody was listening await client.query( "SELECT 'subscription' AS type, id FROM stripe.subscriptions WHERE updated_at > $1", [since]); // handle each row, then:
client.on("notification", (msg) => { const { type, id } = JSON.parse(msg.payload); // read the row, act });
Python (psycopg 3) conn = psycopg.connect(db_url, autocommit=True) # dedicated, not a pool conn.execute("LISTEN driftless_changes")
catch up on anything missed while nobody was listening
conn.execute( "SELECT 'subscription', id FROM stripe.subscriptions WHERE updated_at > %s", (since,))handle each row, then:
for notice in conn.notifies(): change = json.loads(notice.payload) # read the row, act
Production notes:
The listener needs a direct or session-pooled connection. Transaction-mode poolers (pgbouncer, Supavisor) hand statements to arbitrary backend sessions, so LISTEN through them silently receives nothing. Everything else in your app can keep using the pooler. Widen the catch-up query to every table your worker reacts to, and persist your own since cursor. On Supabase, this works on the direct connection; alternatively, subscribe to changes on stripe.* tables with Supabase Realtime and skip LISTEN/NOTIFY entirely.
The event log is your archive. Separate from the mirror tables, driftless.events keeps raw events past Stripe's roughly 30-day window (90 days by default, retention.events_days: 0 for forever): an audit trail for "what exactly did Stripe send", inspectable with driftless events show evt_.... What you delete from your codebase: the webhook endpoint, the dedupe table, and the resync cron. Keeping all that correct is now driftless's job, and verify proves it is doing it. How it works Two dependencies: the driftless binary and your Postgres (17+). No queue, no broker, no third-party service.
Receive: an HTTP endpoint for Stripe webhooks with signature verification, secret rotation support, and ack-after-commit semantics: Stripe never gets a 200 until the event is durably recorded. Dedupe & order: every event lands in an append-only log exactly once; object state is applied in a way that tolerates duplicates, out-of-order delivery, and same-second timestamp ties. Gap detection: a sweeper polls Stripe's event list on a schedule and finds the events your endpoint was never sent (misconfigured URLs, load balancers eating deliveries, downtime past Stripe's retry window) before the 30-day event-log cliff erases the evidence. It alarms loudly when the cliff approaches. Backfill: full or incremental history import through the REST API, resumable across restarts and kill -9, including the canceled subscriptions the default listing hides. Materialize: clean per-object tables (stripe.customers, stripe.invoices, stripe.subscriptions, …) with soft deletes, generated columns, and the raw JSON preserved. Prove: driftless verify reconciles your database against Stripe and reports (or repairs) any drift.
Guarantees Precisely what is promised:
Every event Stripe delivers is recorded exactly once, or Stripe gets a non-2xx and retries. Every event Stripe generates but fails to deliver is found by the sweeper while the events API still holds it. Object rows converge to Stripe's current truth regardless of delivery order, duplication, or same-second ties. A kill -9 at any moment loses no acknowledged data.
Note what the list does not say: "exactly-once delivery" is not a thing anyone can promise you. Recorded-exactly-once and provable convergence are. Drift as a failing CI check verify --format json exits 3 when drift is found, which turns "is billing data correct?" into a check that can page you: name: billing-drift on: schedule: - cron: "0 6 *" jobs: verify: runs-on: ubuntu-latest steps: - run: | docker run --rm \ -e DRIFTLESS_DATABASE_URL="${{ secrets.DRIFTLESS_DATABASE_URL }}" \ -e DRIFTLESS_STRIPE_API_KEY="${{ secrets.STRIPE_RK_KEY }}" \ ghcr.io/quyumkehinde/driftless:latest verify --full --format json A failing run means your database and Stripe disagree; the job log contains the exact objects and the kind of divergence. verify --repair fixes what it finds. Migrating from stripe/sync-engine Driftless mirrors sync-engine's table naming, and driftless import migrates a sync-engine database in place with zero copying of your Postgres data out:
stop sync-engine, then rename its schema out of the way (instant, metadata-only)
psql "$DATABASE_URL" -c 'ALTER SCHEMA stripe RENAME TO sync_engine'driftless migrate up # creates the fresh stripe schema driftless import --from-sync-engine # copies rows, table by table driftless verify --repair # re-fetches true state for every object driftless serve # from here on, it stays correct
when satisfied:
psql "$DATABASE_URL" -c 'DROP SCHEMA sync_engine CASCADE' Import reconstructs objects from sync-engine's typed columns and marks them import-sourced; the verify --repair step re-fetches every one of them from Stripe, so the mirror ends byte-true regardless of what the old pipeline had dropped. AlternativesDriftless stripe/sync-engine Hookdeck / Svix Airbyte / Fivetran
Webhook receive + signature check yes yes yes no
Dedupe guaranteed yes partial best-effort, documented as such n/a
Out-of-order and same-second safe yes known open issue disclaimed n/a
Detects never-delivered events yes no no no
Resumable backfill incl. canceled subscriptions yes known open issue no polling sync
Reconciliation proof verify, exit-code contract no no no
Where your data lives your Postgres your Postgres their cloud, then yours your warehouse, hours later
Operating it Full command reference, generated from the binary's own help text, lives in docs/cli/.
Command Does
serve run everything: ingest, workers, sweeper, retention, nightly verify
init first-run setup: checks, migrations, offer of a backfill
backfill import history, resumable, --since / --type / --dry-run
verify reconcile against Stripe; exit 3 on drift; --repair fixes
status one-screen health summary
doctor environment checks with copy-paste fixes
jobs list / jobs retry inspect and requeue dead work
events show dump one stored event, payload included
import migrate from stripe/sync-engine
migrate up / migrate status schema migrations, embedded, forward-only
config print effective configuration, secrets redacted
Prometheus metrics on :8725/metrics; recommended alert rules with runbook annotations ship in contrib/alerts.yaml. Deploy recipes for Docker Compose, systemd, and Kubernetes live in deploy/. Replicas beyond one are safe: work is claimed with SKIP LOCKED and scheduled jobs elect a leader per pass.
Limits Stripe only. Self-hosted only. Postgres only. No Stripe Connect yet (single-account mirrors). Payment methods are mirrored shallowly (per-customer, for customers with subscriptions). Everything that makes the mirror trustworthy lives in the open-source binary and always will; a paid tier, if one ever exists, only adds hosting and dashboards on top. If that is the part you want, leave an email on the waitlist. Status Release candidate. The chaos suite (crash-kill during load, backfill kill -9 and resume, 429 storms, delivered-shuffled-and-duplicated convergence) runs green under -race in CI. v1.0.0 follows a soak period on live accounts. License Apache-2.0. No CLA.