Verifiable usage ledger
August 17, 2026 ยท View on GitHub
Last modified: 2026-08-16
The usage ledger turns the stream of completed LLM calls into a tamper-evident, optionally signed record. Where a plain usage sink ships events outward best-effort and unsigned, the ledger lets you prove what was spent: each entry is hash-chained to the one before it, so editing any past record breaks every link after it, and with a signing seed configured each entry is Ed25519-signed so the record is attributable to the proxy that wrote it.
All of this ships in the Apache-2.0 binary. The proxy writes the chain and verifies it. It does not publish entries to an external transparency log or a reporting portal, so anchoring to one is something you build on top of the same entries.
How it works
Configure a ledger usage sink on an ai_proxy origin:
action:
type: ai_proxy
providers:
- name: openai
provider_type: openai
api_key: ${OPENAI_API_KEY}
default_model: gpt-4o-mini
models: [gpt-4o-mini]
usage_sinks:
- type: ledger
path: /var/lib/sbproxy/usage-ledger.jsonl
# Optional. A 32-byte Ed25519 seed as 64 hex chars. When set, every
# entry is signed. Resolve it from a secret manager in production.
signing_seed_hex: ${LEDGER_SIGNING_SEED_HEX}
After every completed call the gateway appends one entry:
{
"seq": 0,
"recorded_at": "2026-06-24T18:00:00.000000000+00:00",
"prev_hash": "0000...0000",
"entry_hash": "9f86d0...",
"signature": "a1b2c3...",
"event": {
"provider": "openai",
"model": "gpt-4o-mini",
"prompt_tokens": 12,
"completion_tokens": 8,
"total_tokens": 20,
"cost_usd": 0.0000035,
"latency_ms": 240,
"status": 200,
"key_id": "team-a",
"request_id": "req-..."
}
}
entry_hashis the SHA-256 ofprev_hash || seq || recorded_at || event, so the chain binds the order, the timestamp, and the event content. Changing any field of any past entry changes itsentry_hash, which no longer matches the next entry'sprev_hash.signature, when present, is the Ed25519 signature over the entry's raw digest. A receipt verifies against the public key derived from the seed; a forged or re-signed entry does not.request_idis the dedup key. The ledger file is replayed on open, so a retried or at-least-once delivery of the same request collapses to a single entry (exactly-once).
Durability
The ledger file is its own write-ahead log. Each entry is serialized, written, and flushed before the append returns, all under a lock. A local append is sub-millisecond, so it stays off the network hot path while never dropping an event under a burst. Emission happens from the end-of-request hook, after the response is already sent, so it never adds latency to the call it records, and a ledger problem can never fail the request.
Verifying
Re-derive the chain and check every link:
sbproxy ai ledger verify /var/lib/sbproxy/usage-ledger.jsonl
Add the seed to also verify signatures:
sbproxy ai ledger verify /var/lib/sbproxy/usage-ledger.jsonl \
--signing-seed-hex "$LEDGER_SIGNING_SEED_HEX"
On success it prints the entry count and exits 0. On a broken chain or a
bad signature it names the first failing sequence number and exits 1, so
it slots into a cron check or CI gate. --format json emits a structured
object for tooling.
Reconciling against a provider export
Verification proves the ledger has not been tampered with. It cannot
prove the ledger is complete: a call that never went through this
gateway never produces a ledger entry to check. ledger reconcile
closes part of that gap by comparing the ledger against a usage export
you download from the provider directly, per day and model:
sbproxy ai ledger reconcile /var/lib/sbproxy/usage-ledger.jsonl \
--provider-export openai-usage-export.json \
--format openai-usage --strict
--provider-export is a file you fetch yourself, ahead of time, with an
Admin API key:
GET /v1/organization/usage/completions?bucket_width=1d&group_by[]=model
against
OpenAI's organization Usage API
(worked example:
the OpenAI cookbook).
openai-usage is the only supported --format today; it was picked
over
Anthropic's Admin usage/cost API
because Anthropic's usage rows split input tokens across
uncached_input_tokens, cache_read_input_tokens, and a nested
cache_creation object, which needs a policy decision about which of
those count toward "what the ledger should have seen" before they are
comparable; OpenAI's flat input_tokens / output_tokens pair does
not.
The command always re-derives the ledger's hash chain first (the same
check ledger verify runs) and refuses to reconcile an unverified one.
It then reports two things:
- Bypass evidence: a (day, model) row the export shows requests for that the ledger has no matching request for at all. This is the strongest signal: spend the provider counted that the gateway's own metering path never recorded.
- Ledger-only rows: a (day, model) row the ledger recorded that the export does not show. Not bypass evidence by itself; see the caveats below.
Without --strict the command always exits 0, so a first run can be
read before it is wired into anything. With --strict it exits 1 when
bypass evidence is found, so it slots into a scheduled reconciliation
job the way ledger verify slots into a CI gate.
What this does and does not prove. A clean reconcile run proves
only that, for the org and API key that produced this export, every
request the provider billed also has a matching entry in this gateway's
ledger. It does not prove nothing else was spent: a call made under a
different key, project, or provider organization produces no row at
all, on either side, so it is invisible to this check. A ledger-only
row is usually a clock-window edge (the export's daily bucket boundary
and the ledger's recorded_at are not guaranteed to land on the same
side of midnight UTC) or provider usage data that has not caught up yet
(both OpenAI and Anthropic can lag by minutes), not evidence of
anything wrong.
Example run
The fixture this repository ships for its own tests,
crates/sbproxy-ai/tests/fixtures/openai-usage-export.json, is a real
OpenAI organization Usage API shape (with invented numbers) covering two
days and three (day, model) rows. Running reconcile against a freshly
created, empty ledger shows the report at its most stark: every
provider-side row has nothing on the ledger side to match.
sbproxy ai ledger reconcile /var/lib/sbproxy/usage-ledger.ndjson \
--provider-export crates/sbproxy-ai/tests/fixtures/openai-usage-export.json \
--format openai-usage
usage ledger reconcile: /var/lib/sbproxy/usage-ledger.ndjson vs crates/sbproxy-ai/tests/fixtures/openai-usage-export.json (openai-usage)
chain: verified (chain only, no signing seed given)
rows compared: 3
bypass evidence (provider export shows usage the ledger never recorded):
2026-06-24 gpt-4o 47 request(s) unseen by the ledger (125500 token(s))
2026-06-24 gpt-4o-mini 210 request(s) unseen by the ledger (60500 token(s))
2026-06-25 gpt-4o-mini 63 request(s) unseen by the ledger (19300 token(s))
total: 320 request(s) unseen by the ledger
ledger-only: none.
This only proves bypass for usage visible to the provider org and API key that produced this export: a different org, project, or key would not appear here at all. Clock-window edges (the export's bucket boundary vs. the ledger's recorded_at) and key/org attribution differences can also put a row on one side only; treat a ledger-only row as a lead, not proof.
An empty ledger is the degenerate case: every provider row reads as
bypass evidence because there is nothing on the ledger side to match it
against. A ledger that has actually been recording usage would show
real matches wherever the gateway saw the traffic, and bypass evidence
only on the rows it didn't. Add --strict to make that distinction
actionable: the command exits 1 the moment bypass evidence is
non-empty, which is what a scheduled reconciliation job wants, rather
than a first, exploratory run.
Try it
The runnable example is in
examples/ai-usage-ledger/. Drive a
request through it, inspect the JSONL, then edit a cost_usd in the file
and watch verify fail at the mutated sequence number.