API reference
A multi-currency, double-entry, point-in-time-queryable ledger. Sign up for an API key, post a balanced transaction with curl, query the balance at any past timestamp.
Base URL: https://api.rithvikronaldo.dev
Quickstart
Sixty seconds, four steps.
- 1. Sign up at the dashboard and copy the
ac_…key. - 2. Create your first account:
curl -X POST https://api.rithvikronaldo.dev/agents \
-H "Authorization: Bearer ac_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "treasury", "currency": "USD"}'- 3. Within five seconds the dashboard's left rail shows your new account.
- 4. Post a balanced transaction (see Transactions).
Authentication
Every authenticated request carries an Authorization: Bearer ac_… header. The middleware looks up the tenant by SHA-256 hash of the key and stashes the tenant id on the request. Every handler downstream reads from there.
Requests without a Bearer header resolve to the public demo tenant — that's what the unsigned dashboard sees.
The raw key is returned exactly once at signup. We never store the raw value; the database only sees the hash. If the DB leaks, the keys aren't usable directly.
Tenants & signup
/tenantsCreate a tenant — or, if the email already exists, rotate its API key and return the existing tenant. Idempotent on email. The `created` flag distinguishes a fresh signup (true) from a rotation (false). The raw key is shown once; the database only stores its SHA-256 hash.
curl -X POST https://api.rithvikronaldo.dev/tenants \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com", "name": "Acme"}'{
"tenant": {
"id": "8f3e…",
"email": "you@example.com",
"name": "Acme",
"created_at": "2026-05-18T09:12:43Z"
},
"api_key": "ac_REPLACE_WITH_YOUR_KEY",
"created": true,
"api_key_warning": "Save this key — it will not be shown again."
}/tenants/meReturns the tenant the current Bearer key resolves to. Falls back to the demo tenant for unauthenticated requests.
curl https://api.rithvikronaldo.dev/tenants/me \ -H "Authorization: Bearer ac_YOUR_KEY"
{
"id": "8f3e…",
"email": "you@example.com",
"name": "Acme",
"created_at": "2026-05-18T09:12:43Z"
}Accounts
Accounts are the leaves of the ledger. An account has a code, a currency, and a type (asset, liability, revenue, etc.). Entries write to accounts in their native currency.
/agentsCreate an account. Returns the account code derived from the new account's UUID.
curl -X POST https://api.rithvikronaldo.dev/agents \
-H "Authorization: Bearer ac_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "treasury", "currency": "USD"}'{
"id": "c8b25369-…",
"name": "treasury",
"status": "active",
"currency": "USD",
"account_code": "agent_c8b25369_usd",
"created_at": "2026-05-18T09:13:01Z"
}/agentsList the accounts under the current tenant.
curl https://api.rithvikronaldo.dev/agents \ -H "Authorization: Bearer ac_YOUR_KEY"
Transactions
Every transaction is a set of entries that sum to zero per currency. The invariant Σ in = Σ out is enforced twice — in Go before the write, and again at COMMIT by a Postgres trigger.
Amounts are integer minor units. 1000000 means ₹10,000.00 (paise) or $10,000.00 (cents) depending on the row's currency.
/transactionsPost a balanced transaction. Use the Idempotency-Key header so retries don't double-write. A request with a known key replays the original response and adds Idempotent-Replay: true.
curl -X POST https://api.rithvikronaldo.dev/transactions \
-H "Authorization: Bearer ac_YOUR_KEY" \
-H "Idempotency-Key: settle_2026_05_29_batch_001" \
-H "Content-Type: application/json" \
-d '{
"description": "Card settlement — vendor split",
"occurred_at": "2026-05-29T14:32:00Z",
"entries": [
{"account": "cash", "amount": 100000, "currency": "USD", "direction": "out"},
{"account": "vendor_pool", "amount": 85000, "currency": "USD", "direction": "in"},
{"account": "fees", "amount": 13000, "currency": "USD", "direction": "in"},
{"account": "reserve", "amount": 2000, "currency": "USD", "direction": "in"}
]
}'/transactionsList transactions for the current tenant, newest first. Cursor pagination via the `cursor` query param.
curl "https://api.rithvikronaldo.dev/transactions?limit=50" \ -H "Authorization: Bearer ac_YOUR_KEY"
422 unbalanced— Σ in ≠ Σ out per currency. Body hascurrency, in, out, diff.422 idempotency_hash_mismatch— same key, different body. Client bug — fix and retry.409 idempotency_pending— a request with the same key is still processing.200 OK+Idempotent-Replay: true— retry succeeded; original response replayed.
Point-in-time balances
GET /accounts/:code/balance returns three numbers: balance (realised), available (balance minus pending authorizations), and on_hold (sum of pending). The as_of and in query params do the time-travel and FX conversion.
/accounts/:code/balanceBalance now, in the account's native currency.
curl https://api.rithvikronaldo.dev/accounts/treasury/balance \ -H "Authorization: Bearer ac_YOUR_KEY"
/accounts/:code/balance?as_of=&in=Balance at a past timestamp, converted to a target currency using the FX rate that was current at that timestamp.
curl "https://api.rithvikronaldo.dev/accounts/treasury/balance?as_of=2026-04-21T14:32:00Z&in=EUR" \ -H "Authorization: Bearer ac_YOUR_KEY"
{
"account": "treasury",
"currency": "EUR",
"balance": 11891,
"as_of": "2026-04-21T14:32:00Z"
}Stress / load test
Bulk-post N balanced transactions through the same ledger.Post() primitive every other write uses. No fast path, no fixtures — real entries, real commits, same invariant check. The endpoint is rate-limited to signed-in tenants and capped at N = 10,000 per call. Used by the dashboard's ▶ Stress 1k button and by anyone wanting to measure their own ledger's throughput honestly.
/stressRun a bulk-post stress test against the current tenant. `n` is the number of transactions to post; `concurrency` (optional, default 1) spreads them across N goroutines. Returns aggregate timing + the invariant-violations counter (always 0 by construction — every Post() runs CheckBalanced + the deferred Postgres trigger).
curl -X POST https://api.rithvikronaldo.dev/stress \
-H "Authorization: Bearer ac_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"n": 1000, "concurrency": 4}'{
"n_posted": 1000,
"elapsed_ms": 2037,
"tps_peak": 491.2,
"p50_commit_ms": 7.9,
"p99_commit_ms": 14.5,
"invariant_violations": 0,
"serialization_retries": 0,
"currency": "USD"
}Numbers above are from an M-series local + Postgres in Docker. serialization_retries stays at 0 because ledger.Post() is READ COMMITTED + append-only — no read-modify-write on account rows, no FOR UPDATE, nothing for Postgres to abort and retry. Contention shows up as lock-wait latency, not 40001 errors.
Events stream (SSE)
Server-Sent Events. Subscribe with any HTTP client that speaks text/event-stream. Auto-reconnects from the browser; the dashboard uses this for the live transaction stream.
/events/streamStream of ledger events for the current tenant. Demo tenant by default; sign in to filter to your own.
curl -N https://api.rithvikronaldo.dev/events/stream
Multi-tenancy
Every signup creates a tenant. Every row in accounts, transactions, authorizations, idempotency_keys, and agents carries a tenant_id. Every query filters by it. Cross-tenant isolation is enforced by predicate, tested by go test -run CrossTenant (five integration tests covering read and write paths).
Trade-off chosen over Postgres Row-Level Security: the predicate lives in the SQL where EXPLAIN can see it, not in a policy that has to be reverse-engineered when a row goes missing. RLS ties the predicate to a session variable inside a transaction; pgx pools recycle connections; one leaked SET LOCAL away from a cross-tenant read. The predicate written in every SQL string is uglier but grep-able and code-reviewable.