How it works¶
Gringotts keeps credits correct with a small, auditable data model and a few invariants it never breaks.
The ledger and the balance¶
Four tables hold everything:
users— one row per API consumer: the SHA-256 hash of their key (the key itself is shown once and never stored), the last four characters for display, and the cached balancecredits.credit_transactions— an append-only ledger. Every charge, refund, grant, purchase, clawback, and reinstatement is one signed row (amountis negative for charges and clawbacks, positive otherwise), written in the same database transaction as the balance update.idempotency_records— the response cache and lock for keyed requests, uniquely indexed by caller andIdempotency-Key.checkout_orders— the exact user, credits, amount, and currency authorized before Stripe Checkout is created. A webhook must match this row exactly before it can grant credits.
So the cached credits is a fast read, but the ledger is the source of truth:
the sum of a user’s amount values always equals their balance.
Built-in routes revalidate API-key and admin authorization before replaying a
stored response. Host routes must provide idempotency_replay_validator to
revalidate mutable authorization; without one, retries return 409 while the
lock still prevents duplicate execution.
Running balance and three-way reconcile¶
Each ledger row also stores balance_after — the user’s balance immediately
after that row. That makes the balance auditable at every point in history, and
it makes drift structurally detectable rather than merely possible to notice.
gringotts reconcile (and the admin path) run a three-way check per user:
cached
credits== runningSUM(amount)== the latest row’sbalance_after, and every earlier row’sbalance_afterequals its cumulative sum.
If any of those disagree, the user is reported. In normal operation they never do — every write goes through one transaction that updates the balance and appends the row together.
Never negative¶
A charge is a single compare-and-set — UPDATE ... SET credits = credits - :cost WHERE credits >= :cost — so two concurrent requests can’t overspend, with no
row locks needed on PostgreSQL. Two database CHECK constraints back the
invariant up: credits >= 0 on users and balance_after >= 0 on the ledger.
Concurrency¶
Charges use the compare-and-set above. Refund, grant, and clawback take a
per-user write lock first (a real UPDATE that acquires a row lock on Postgres
and the database write lock on SQLite) so their read-modify-write — including the
cumulative refund math — is serialized. On SQLite the engine also ships WAL and a
busy_timeout so concurrent writers wait rather than erroring.
Migrations¶
Schema changes ship as forward-only, idempotent steps applied in place by
gringotts migrate — no “drop and recreate.” It refuses to run if the ledger
does not already reconcile, so it never backfills on top of drift. init-db
creates a fresh schema already at the latest version.
The 402 response¶
When a key has too few credits, gringotts returns 402 Payment Required with a
frozen, machine-readable body (x402-compatible vocabulary) carrying the cost,
balance, and an accepts array pointing at the purchase page — so an AI-agent
client can parse it and pay. The exact shape is documented in the README.