Go library + HTTP servicePostgreSQLApache-2.0

ledger

A double-entry ledger for Go and PostgreSQL. Every transaction balances, entries never change, and amounts are exact to 78 digits, enforced by the database itself. Use it inside your application, or run it as ledgerd.

go get github.com/otyang/ledger
txn · deposit-1USD · 2 decimals
AccountDebitCredit
@world5,403.23
acc_alice5,403.23
Total5,403.235,403.23
One transaction, two entries. Stored as 540323 cents each side. Debits equal credits, so the books stay balanced.

Documentation

PageRead it to
Beginner guideUnderstand double-entry bookkeeping and this ledger through a worked example: top-up, purchase with a fee, refund, card hold, withdrawal
Interactive API referenceBrowse 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 internalsLearn 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.

Quick start

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

Run locally

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

Or without the Makefile:

shell
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

shell
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

shell
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).

VariableDefaultDescription
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:7710Listen address.
LEDGER_REQUEST_TIMEOUT10sUpper bound for each request, including time spent waiting for account locks.
LEDGER_INSECURE_NO_AUTHfalseSet to true to run without a token. Only do this when something in front of the service already authenticates requests.
LEDGER_RATE_LIMIT0 (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_BURSTthe rateHow many requests a client may send at once before the rate applies.
LEDGER_HOLD_EXPIRY_INTERVAL1mHow often expired holds are released. 0 disables it. Holds without expires_at are never touched.
LEDGER_SNAPSHOT_INTERVAL5mHow 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_SECRETRequired with LEDGER_WEBHOOK_URL: signs each request so the receiver can verify it.
LEDGER_SHUTDOWN_DRAIN0On 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

MethodPathSuccessDescription
POST/currencies201Register a currency and its decimals
GET/currencies/{code}200Get a currency
POST/ledgers201Create a ledger
GET/ledgers/{id}200Get a ledger
POST/accounts201Create an account
GET/accounts/{id}200Get an account with its balances
GET/accounts/{id}/entries?after=&limit=200List an account's entries, oldest first
GET/accounts/{id}/balance?at=200Balance at a point in time (RFC 3339; default now)
POST/transactions201Post a transaction
GET/transactions/{id}200Get a transaction with its entries
POST/transactions/{id}/refunds201Refund all or part of a transaction
GET/transactions/{id}/refundable200What is left to refund
POST/holds201Reserve funds
GET/holds/{id}200Get a hold
POST/holds/{id}/commit201Post all or part of a hold as a transaction
POST/holds/{id}/void200Release a hold
PATCH/accounts/{id}200Freeze, unfreeze or close; set the overdraft limit, type or metadata
GET/accounts/{id}/statement?from=&to=200Account statement: opening balance, lines, closing balance
GET/accounts/{id}/statement.csv?from=&to=200The same statement as CSV
PATCH/transactions/{id}200Update a transaction's metadata
POST/transactions/{id}/reversals201Undo everything left of a transaction, with a reason
POST/batches201Post several transactions at once, all or nothing
GET/batches/{id}200Get a batch with its transactions
POST/exchanges201Convert between currencies
GET/ledgers/{id}/transactions200Search transactions by account, date and metadata
GET/ledgers/{id}/trial-balance?at=200Every account's balance, totals per currency
GET/ledgers/{id}/balance-sheet?currency=&at=200Assets, liabilities, equity
GET/ledgers/{id}/income-statement?currency=&from=&to=200Revenue, expenses, net income
POST/ledgers/{id}/periods201Close the books through a date
GET/ledgers/{id}/periods200List closed periods
GET/events?after=&ledger_id=200Every change, in order, for integrations
GET/healthz200The process is alive (no token needed)
GET/readyz200Ready to serve: database reachable, schema up to date (no token needed)
GET/metrics200Prometheus 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

http
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.

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

Ledgers and accounts

http
POST /ledgers

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

{
  "ledger_id": "ldg_…",
  "currency": "USD",
  "indicator": "@world",
  "allow_negative": true,
  "untracked": true
}
Account fieldDescription
currencyMust be registered first, otherwise 422 unknown_currency. Can't change later.
indicatorOptional name such as @world, unique per ledger and currency.
allow_negativeWhether the balance may go below zero. Default false.
untrackedHigh-traffic accounts: no locking, balance computed on read. Requires allow_negative. See Untracked accounts.
metadataAny JSON object.
json
{
  "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

http
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:

json
{
  "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

http
POST /transactions/{id}/refunds

{
  "reference": "refund-1",
  "value": "10.00"
}
BodyRefunds
{"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).

http
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"
    }
  ]
}
http
POST /holds/{id}/commit

{
  "value": "80.00"
}
http
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:

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

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

HTTPcodeMeaning
400invalid_paramsMalformed request: bad JSON, unknown field, invalid amount or value, too many decimals, zero amount
400invalid_idAn ID doesn't have the expected <prefix>_<uuidv7> form
400amount_overflowAn amount or an account total would exceed 78 digits
401unauthorizedMissing or wrong bearer token
404not_foundThe resource in the URL doesn't exist
409already_existsAn account with that indicator and currency already exists in the ledger
409reference_conflictThe reference was already used for a different request
409hold_not_pendingThe hold is already committed or voided
409currency_conflictThe currency is registered with different decimals
409hold_expiredThe hold passed its expires_at
409period_closedThe ledger's books are closed through a later date
422unknown_referenceAn account or ledger named in the body doesn't exist
422unknown_currencyThe currency isn't registered, or its decimals aren't yet
422unbalancedDebits don't equal credits
422currency_mismatchAn account's currency differs from the transaction's
422ledger_mismatchThe accounts belong to different ledgers
422insufficient_fundsThe available balance would go negative
422refund_exceededThe refund is larger than what's left to refund
422account_frozenMoney can't leave a frozen account
422account_closedThe account is closed
429rate_limitedToo many requests from this client; Retry-After is set
503retryableA concurrent update got in the way. Retry the same request; Retry-After is set
503timeoutThe request exceeded LEDGER_REQUEST_TIMEOUT. Retry the same request; Retry-After is set
500internalUnexpected 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

shell
go get github.com/otyang/ledger
go
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

SymptomCause and fix
address :7710 is already in use by another programAnother 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 requiredSet a token, or LEDGER_INSECURE_NO_AUTH=true if a proxy authenticates requests.
422 unknown_currency when creating an accountRegister the currency first: POST /currencies.
422 unknown_currency when sending valueThe currency predates migration 0005; register its decimals once with POST /currencies.
Requests return 503 under loadMany requests are contending for the same account. Retry with the same reference; consider making system accounts untracked.

Development

shell
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:

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

Project layout

PathContents
*.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, NOTICEThe Apache License 2.0 and the copyright notice
AUTHORSThe 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.