ledger

ledger handbook

Everything about running ledger and building on it, in one place. New to double-entry bookkeeping? Start with the beginner guide. Just want the endpoints? Open the interactive API reference.

Documentation

Page Read it to
Beginner guide Understand double-entry bookkeeping and this ledger through a worked example: top-up, purchase with a fee, refund, card hold, withdrawal
Interactive API reference Browse every endpoint, its schemas and code samples, generated from the OpenAPI 3.0 spec
API reference (Markdown) Read every endpoint’s fields, status codes, errors and real example responses
Architecture and internals Learn how posting, locking, idempotency, holds, refunds and the database rules work, before changing the code

This handbook is also the home page of the website, otyang.github.io/ledger/docs.

Contents

Quick start

You need Go 1.25+ and PostgreSQL (any recent version; tests run on 18). Run these from the repository root.

Run locally

createdb ledger
cp .env.example .env        # then edit LEDGER_DATABASE_URL and LEDGER_API_TOKEN
make run

Or without the Makefile:

LEDGER_DATABASE_URL="postgres://postgres@localhost:5432/ledger?sslmode=disable" \
LEDGER_API_TOKEN=change-me \
go run ./cmd/ledgerd

The service applies its database migrations on startup, then logs ledgerd listening on port 7710.

Run with Docker

make docker-build VERSION=v0.1.0
docker run --rm -p 7710:7710 \
  -e LEDGER_DATABASE_URL="postgres://postgres@host.docker.internal:5432/ledger?sslmode=disable" \
  -e LEDGER_API_TOKEN=change-me \
  ledgerd:v0.1.0

host.docker.internal reaches a Postgres running on your machine (Docker Desktop). The image contains only the static binary, runs as a non-root user, and has no shell.

First requests

T="Authorization: Bearer change-me"
API=http://localhost:7710

curl -H "$T" -X POST $API/currencies -d '{"code": "USD", "decimals": 2}'
curl -H "$T" -X POST $API/ledgers    -d '{"name": "main"}'
curl -H "$T" -X POST $API/accounts   -d '{
  "ledger_id": "ldg_…",
  "currency": "USD",
  "indicator": "@world",
  "allow_negative": true,
  "untracked": true
}'
curl -H "$T" -X POST $API/accounts   -d '{"ledger_id": "ldg_…", "currency": "USD"}'

# Deposit 5403.23 USD from outside the ledger into the new account
curl -H "$T" -X POST $API/transactions -d '{
  "reference": "deposit-1",
  "currency": "USD",
  "legs": [
    {
      "account_id": "acc_<world>",
      "direction": "debit",
      "value": "5403.23"
    },
    {
      "account_id": "acc_<alice>",
      "direction": "credit",
      "value": "5403.23"
    }
  ]
}'

curl -H "$T" "$API/accounts/acc_<alice>"

Configuration

ledgerd is configured with environment variables. make run also reads them from a .env file (see .env.example).

Variable Default Description
LEDGER_DATABASE_URL (required) Postgres connection URL. All tables go in a ledger schema inside this database, so it can be shared with other applications.
LEDGER_API_TOKEN (required) Clients must send Authorization: Bearer <token>. One token grants access to all ledgers.
LEDGER_ADDR :7710 Listen address.
LEDGER_REQUEST_TIMEOUT 10s Upper bound for each request, including time spent waiting for account locks.
LEDGER_INSECURE_NO_AUTH false Set to true to run without a token. Only do this when something in front of the service already authenticates requests.
LEDGER_RATE_LIMIT 0 (off) Requests per second allowed per client IP. Excess requests get 429 rate_limited. Behind a proxy, all clients share its IP, so limit at the proxy instead.
LEDGER_RATE_BURST the rate How many requests a client may send at once before the rate applies.
LEDGER_HOLD_EXPIRY_INTERVAL 1m How often expired holds are released. 0 disables it. Holds without expires_at are never touched.
LEDGER_SNAPSHOT_INTERVAL 5m How often untracked accounts’ balance snapshots are refreshed, which keeps reading their balance fast. 0 disables it.
LEDGER_WEBHOOK_URL (off) Send every event to this URL.
LEDGER_WEBHOOK_SECRET   Required with LEDGER_WEBHOOK_URL: signs each request so the receiver can verify it.
LEDGER_SHUTDOWN_DRAIN 0 On shutdown, keep serving this long with /readyz failing, so a load balancer stops sending traffic before the listener closes. 10s suits most load balancers.

ledgerd -version prints the version the binary was built with.

HTTP API

Conventions

Endpoints

Method Path Success Description
POST /currencies 201 Register a currency and its decimals
GET /currencies/{code} 200 Get a currency
POST /ledgers 201 Create a ledger
GET /ledgers/{id} 200 Get a ledger
POST /accounts 201 Create an account
GET /accounts/{id} 200 Get an account with its balances
GET /accounts/{id}/entries?after=&limit= 200 List an account’s entries, oldest first
GET /accounts/{id}/balance?at= 200 Balance at a point in time (RFC 3339; default now)
POST /transactions 201 Post a transaction
GET /transactions/{id} 200 Get a transaction with its entries
POST /transactions/{id}/refunds 201 Refund all or part of a transaction
GET /transactions/{id}/refundable 200 What is left to refund
POST /holds 201 Reserve funds
GET /holds/{id} 200 Get a hold
POST /holds/{id}/commit 201 Post all or part of a hold as a transaction
POST /holds/{id}/void 200 Release a hold
PATCH /accounts/{id} 200 Freeze, unfreeze or close; set the overdraft limit, type or metadata
GET /accounts/{id}/statement?from=&to= 200 Account statement: opening balance, lines, closing balance
GET /accounts/{id}/statement.csv?from=&to= 200 The same statement as CSV
PATCH /transactions/{id} 200 Update a transaction’s metadata
POST /transactions/{id}/reversals 201 Undo everything left of a transaction, with a reason
POST /batches 201 Post several transactions at once, all or nothing
GET /batches/{id} 200 Get a batch with its transactions
POST /exchanges 201 Convert between currencies
GET /ledgers/{id}/transactions 200 Search transactions by account, date and metadata
GET /ledgers/{id}/trial-balance?at= 200 Every account’s balance, totals per currency
GET /ledgers/{id}/balance-sheet?currency=&at= 200 Assets, liabilities, equity
GET /ledgers/{id}/income-statement?currency=&from=&to= 200 Revenue, expenses, net income
POST /ledgers/{id}/periods 201 Close the books through a date
GET /ledgers/{id}/periods 200 List closed periods
GET /events?after=&ledger_id= 200 Every change, in order, for integrations
GET /healthz 200 The process is alive (no token needed)
GET /readyz 200 Ready to serve: database reachable, schema up to date (no token needed)
GET /metrics 200 Prometheus metrics

Every field and response is in the interactive API reference, where you can also try requests against a local ledgerd, and in the Markdown API reference.

Currencies

POST /currencies

{
  "code": "USD",
  "decimals": 2
}

code is 3–10 uppercase letters or digits (USD, ETH, USDC). decimals (0–36) is required: it is how many digits follow the decimal point, and it can never change once registered, because every stored amount would change meaning. Registering the same code with the same decimals again is a no-op; different decimals return 409 currency_conflict.

{
  "code": "USD",
  "decimals": 2,
  "created_at": "2026-10-12T12:51:07.593863Z"
}

Ledgers and accounts

POST /ledgers

{
  "name": "main",
  "metadata": {
    "team": "payments"
  }
}
POST /accounts

{
  "ledger_id": "ldg_…",
  "currency": "USD",
  "indicator": "@world",
  "allow_negative": true,
  "untracked": true
}
Account field Description
currency Must be registered first, otherwise 422 unknown_currency. Can’t change later.
indicator Optional name such as @world, unique per ledger and currency.
allow_negative Whether the balance may go below zero. Default false.
untracked High-traffic accounts: no locking, balance computed on read. Requires allow_negative. See Untracked accounts.
metadata Any JSON object.
{
  "id": "acc_…",
  "ledger_id": "ldg_…",
  "currency": "USD",
  "allow_negative": false,
  "untracked": false,
  "credit_total": "540323",
  "credit_total_value": "5403.23",
  "debit_total": "0",
  "debit_total_value": "0.00",
  "balance": "540323",
  "balance_value": "5403.23",
  "pending_debit": "0",
  "pending_debit_value": "0.00",
  "pending_credit": "0",
  "pending_credit_value": "0.00",
  "version": 1,
  "created_at": "…",
  "updated_at": "…"
}

GET /accounts/{id}/entries pages with after=<last entry id> and limit (default 100, max 1000), and returns {"entries": [...]}. GET /accounts/{id}/balance?at=2026-10-12T12:00:00Z returns {"account_id", "balance", "balance_value", "at"}.

Transactions

POST /transactions

{
  "reference": "order-8841",
  "currency": "USD",
  "description": "Concert ticket",
  "metadata": {
    "customer_id": "c_77"
  },
  "legs": [
    {
      "account_id": "acc_<alice>",
      "direction": "debit",
      "value": "50.00"
    },
    {
      "account_id": "acc_<venue>",
      "direction": "credit",
      "value": "47.00"
    },
    {
      "account_id": "acc_<platform>",
      "direction": "credit",
      "value": "3.00"
    }
  ]
}

A transaction needs at least two legs across at least two accounts, all in the transaction’s currency and ledger, and its debits must equal its credits. A debit lowers an account’s balance and a credit raises it. The response contains one entry per leg:

{
  "id": "txn_…",
  "ledger_id": "ldg_…",
  "reference": "order-8841",
  "status": "posted",
  "currency": "USD",
  "description": "Concert ticket",
  "metadata": {
    "customer_id": "c_77"
  },
  "created_at": "…",
  "entries": [
    {
      "id": "ent_…",
      "transaction_id": "txn_…",
      "account_id": "acc_<alice>",
      "direction": "debit",
      "amount": "5000",
      "value": "50.00",
      "balance_after": "490323",
      "balance_after_value": "4903.23",
      "account_version": 2,
      "created_at": "…"
    },
    {
      "id": "ent_…",
      "transaction_id": "txn_…",
      "account_id": "acc_<venue>",
      "direction": "credit",
      "amount": "4700",
      "value": "47.00",
      "balance_after": "4700",
      "balance_after_value": "47.00",
      "account_version": 1,
      "created_at": "…"
    },
    {
      "id": "ent_…",
      "transaction_id": "txn_…",
      "account_id": "acc_<platform>",
      "direction": "credit",
      "amount": "300",
      "value": "3.00",
      "balance_after": "300",
      "balance_after_value": "3.00",
      "account_version": 1,
      "created_at": "…"
    }
  ]
}

Refunds

POST /transactions/{id}/refunds

{
  "reference": "refund-1",
  "value": "10.00"
}
Body Refunds
{"reference": "…"} Everything not yet refunded
{"reference": "…", "value": "10.00"} or "amount" Part of a two-leg transaction
{"reference": "…", "legs": [...]} Specific legs of a multi-leg transaction, e.g. take money back from the merchant but not the fee account

An explicit zero ("0", "0.00") is rejected; it never means “everything”. Refunds can’t add up to more than the original (422 refund_exceeded). GET /transactions/{id}/refundable returns {"legs": [...]}: what a full refund would post now.

Holds

A hold reserves funds without moving them, like a card authorization. While it’s pending, its debits reduce the source account’s available balance (balance - pending_debit).

POST /holds

{
  "reference": "auth-9",
  "currency": "USD",
  "legs": [
    {
      "account_id": "acc_<alice>",
      "direction": "debit",
      "value": "100.00"
    },
    {
      "account_id": "acc_<bob>",
      "direction": "credit",
      "value": "100.00"
    }
  ]
}
POST /holds/{id}/commit

{
  "value": "80.00"
}
POST /holds/{id}/void

Commit with an empty body to post the whole hold, with amount/value for part of a two-leg hold, or with legs for part of a multi-leg one. Whatever isn’t committed is released. A hold resolves exactly once: retrying the same commit returns the same transaction; anything else returns 409 hold_not_pending. A hold only expires if it was given an expires_at: after that, commits return 409 hold_expired and the funds are released.

Errors

Errors are returned as:

{
  "error": {
    "code": "insufficient_funds",
    "message": "ledger: insufficient funds: account acc_…"
  }
}

Match on code; message is for humans and may change.

HTTP code Meaning
400 invalid_params Malformed request: bad JSON, unknown field, invalid amount or value, too many decimals, zero amount
400 invalid_id An ID doesn’t have the expected <prefix>_<uuidv7> form
400 amount_overflow An amount or an account total would exceed 78 digits
401 unauthorized Missing or wrong bearer token
404 not_found The resource in the URL doesn’t exist
409 already_exists An account with that indicator and currency already exists in the ledger
409 reference_conflict The reference was already used for a different request
409 hold_not_pending The hold is already committed or voided
409 currency_conflict The currency is registered with different decimals
409 hold_expired The hold passed its expires_at
409 period_closed The ledger’s books are closed through a later date
422 unknown_reference An account or ledger named in the body doesn’t exist
422 unknown_currency The currency isn’t registered, or its decimals aren’t yet
422 unbalanced Debits don’t equal credits
422 currency_mismatch An account’s currency differs from the transaction’s
422 ledger_mismatch The accounts belong to different ledgers
422 insufficient_funds The available balance would go negative
422 refund_exceeded The refund is larger than what’s left to refund
422 account_frozen Money can’t leave a frozen account
422 account_closed The account is closed
429 rate_limited Too many requests from this client; Retry-After is set
503 retryable A concurrent update got in the way. Retry the same request; Retry-After is set
503 timeout The request exceeded LEDGER_REQUEST_TIMEOUT. Retry the same request; Retry-After is set
500 internal Unexpected failure; details are logged by the server

Retrying after a 503, or after a network error, is always safe: the reference makes every write idempotent.

Go library

go get github.com/otyang/ledger
import "github.com/otyang/ledger"

db := ledger.OpenDB("postgres://localhost:5432/app?sslmode=disable") // or your own *bun.DB
svc := ledger.New(db)
if err := svc.Migrate(ctx); err != nil { ... }

// Once per currency: how many decimals it has.
svc.RegisterCurrency(ctx, ledger.CurrencyParams{Code: "USD", Decimals: 2})

l, _ := svc.CreateLedger(ctx, ledger.CreateLedgerParams{Name: "main"})
world, _ := svc.EnsureIndicatorAccount(ctx, ledger.CreateAccountParams{
	LedgerID: l.ID, Currency: "USD", Indicator: "@world", AllowNegative: true, Untracked: true,
})
alice, _ := svc.CreateAccount(ctx, ledger.CreateAccountParams{LedgerID: l.ID, Currency: "USD"})
bob, _ := svc.CreateAccount(ctx, ledger.CreateAccountParams{LedgerID: l.ID, Currency: "USD"})

// Amounts in minor units: 600000 cents is 6000.00 USD.
svc.Post(ctx, ledger.Transfer("deposit-123", "USD", world.ID, alice.ID, ledger.NewAmount(600000)))

// The same kind of posting, written as a decimal: Alice pays Bob 5403.23 USD.
svc.Post(ctx, ledger.PostParams{Reference: "pay-1", Currency: "USD", Legs: []ledger.Leg{
	{AccountID: alice.ID, Direction: ledger.Debit, Value: "5403.23"},
	{AccountID: bob.ID, Direction: ledger.Credit, Value: "5403.23"},
}})

// Reserve funds, then capture part of them; the rest is released.
hold, _ := svc.CreateHold(ctx, ledger.HoldParams{Reference: "auth-9", Currency: "USD",
	Legs: ledger.Transfer("", "", alice.ID, bob.ID, ledger.NewAmount(1000)).Legs})
txn, _ := svc.CommitHold(ctx, hold.ID, ledger.CommitHoldParams{Amount: ledger.NewAmount(800)})

// Refund part of it, then the rest.
svc.Refund(ctx, txn.ID, ledger.RefundParams{Reference: "refund-1", Amount: ledger.NewAmount(300)})
svc.Refund(ctx, txn.ID, ledger.RefundParams{Reference: "refund-2"})

Match errors with errors.Is against the sentinels in errors.go (ErrInsufficientFunds, ErrReferenceConflict, …). ErrRetryable covers serialization failures, deadlocks, lock timeouts, and idempotency races that your snapshot can’t see yet.

Using your own database transaction

PostTx, CreateHoldTx, CommitHoldTx, VoidHoldTx and RefundTx run inside your bun.Tx, so ledger movements commit or roll back together with your own writes. GetAccountTx, GetTransactionTx, GetHoldTx and RefundableTx read inside it, including your uncommitted postings.

Testing your application

ledgertest.Run in TestMain, then ledgertest.NewDB(t), gives each test a fresh, migrated database: an embedded Postgres by default, or your own server via LEDGER_TEST_DATABASE_URL. Register the currencies your tests use with RegisterCurrency.

Concepts

Guarantees

Operations

Database and migrations

Everything lives in the ledger Postgres schema, so it never collides with the host application’s tables. ledgerd runs Migrate on every start; library users call svc.Migrate(ctx) themselves.

Upgrade notes:

Deployment

Troubleshooting

Symptom Cause and fix
address :7710 is already in use by another program Another process holds the port. Find it with lsof -nP -iTCP:7710 -sTCP:LISTEN, or pick a free port with LEDGER_ADDR=:7711.
connect to database: … Check LEDGER_DATABASE_URL: host, port, user, database name, and sslmode. The database must exist (createdb ledger). From Docker, use host.docker.internal instead of localhost.
LEDGER_API_TOKEN is required Set a token, or LEDGER_INSECURE_NO_AUTH=true if a proxy authenticates requests.
422 unknown_currency when creating an account Register the currency first: POST /currencies.
422 unknown_currency when sending value The currency predates migration 0005; register its decimals once with POST /currencies.
Requests return 503 under load Many requests are contending for the same account. Retry with the same reference; consider making system accounts untracked.

Development

make help        # list targets
make check       # gofmt, go vet, and all tests with the race detector
make test        # tests only (about 40s)
make build       # bin/ledgerd

Tests start an embedded Postgres (downloaded once, cached in ~/.embedded-postgres-go). To use a local server instead, where each test gets its own throwaway database:

LEDGER_TEST_DATABASE_URL="postgres://postgres@localhost:5432/postgres?sslmode=disable" go test -race ./...

Project layout

Path Contents
*.go (root) The ledger library: accounts, posting, holds, refunds, currencies, amounts, queries
migrations/ SQL migrations, embedded in the binary
httpapi/ The HTTP API (optional; wraps ledger.Service)
cmd/ledgerd/ The standalone service
ledgertest/ Test helpers for this package and for applications embedding it
webhook/ Delivers events to an HTTP endpoint, signed and in order
docs/ Guides, the OpenAPI specification (openapi.yaml), and the GitHub Pages site (index.html, api.html)
tools/site/ Builds docs/index.html (the website’s home page) from this handbook: make site
LICENSE, NOTICE The Apache License 2.0 and the copyright notice
AUTHORS The project’s contributors

License

Copyright 2026 The ledger Authors.

Licensed under the Apache License, Version 2.0. You may use, modify and distribute this software, including in commercial products, provided you keep the license and the NOTICE file. It is provided “as is”, without warranties of any kind.