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 balance credits.

  • credit_transactions — an append-only ledger. Every charge, refund, grant, purchase, clawback, and reinstatement is one signed row (amount is 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 and Idempotency-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 == running SUM(amount) == the latest row’s balance_after, and every earlier row’s balance_after equals 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.