A double-entry ledger. Every entry is balanced, append-only, and enforced by the database as well as the application. Amounts are exchanged as exact decimal strings, never as JSON numbers.
Amounts are strings
A JSON number is a double, so 12.10 is already 12.099999999999999 before the server sees it. Amounts are sent as exact decimal strings and stored as integer minor units.
Retries are safe
Send an Idempotency-Key on any write. The same key with the same body replays the stored response; with a different body it is a 409.
Errors are machine-readable
Failures are RFC 9457 problem documents. Branch on type; the figures you need are extension members, so nothing has to be parsed out of prose.
3 operations
/api/v1/healthPublicLiveness and database readiness
Runs a trivial query against the database. A 503 body reports whether DATABASE_URL is configured and the driver error code, so the cause is visible without reading logs.
200503
/api/v1/webhooks/dispatchPublicDrain the delivery queue
Triggered by the scheduler, authenticated with CRON_SECRET. Documented because an operator needs to know it exists, not because clients should call it.
200401
/api/v1/metricsPublicPrometheus metrics
Text exposition format. obol_ledger_residual_minor is the one worth alerting on: it has exactly one correct value, zero, in every currency.
200
4 operations
/api/v1/accountsPublicList every account with its current balance
200429
/api/v1/accountsBearer tokenOpen an account
201400401409429
/api/v1/accounts/{accountId}PublicFetch one account
200404429
/api/v1/accounts/{accountId}/statementPublicPaginated statement with a running balance
200400404429
7 operations
/api/v1/entriesPublicList journal entries, newest first
200400429
/api/v1/entriesBearer tokenRecord a balanced journal entry
Postings must sum to zero. The entry is written in a single database transaction and a deferred constraint verifies the balance at COMMIT.
200201400401409422429
/api/v1/entries/{entryId}PublicFetch one entry with its postings
200404429
/api/v1/transfersBearer tokenMove money between two accounts
Sugar for a two-legged journal entry; identical guarantees.
200201400401409422429
/api/v1/entries/{entryId}/postBearer tokenSettle a pending entry
Moves the entry from pending to posted: its amounts stop being reserved and start counting toward the posted balance. The overdraft rule is re-checked, because funds available at authorisation may be gone by settlement.
200401404409422429
/api/v1/entries/{entryId}/archiveBearer tokenCancel a pending entry before it settles
Releases the reservation; nothing moves. Distinct from a reversal, which cancels money that did move by posting an opposite entry — here there is nothing to mirror.
200401404409429
/api/v1/entries/{entryId}/reverseBearer tokenUndo an entry by posting its mirror image
A POST that creates a new entry rather than a DELETE that removes one, because that is what happens: the original stays on the record and a second entry cancels it. An entry can be reversed at most once.
201401404409422429
3 operations
/api/v1/reports/balance-sheetPublicAssets, liabilities and equity at a point in time
`balanced` is computed rather than assumed: a balance sheet that does not balance means the ledger is inconsistent.
200400429
/api/v1/reports/income-statementPublicRevenue and expenses over a period
Revenue and expenses are flows, so a period is required. Defaults to the last 30 days rather than all time, because an income statement with no period attached is meaningless.
200400429
/api/v1/reports/trial-balancePublicDebits, credits and residual per currency
A residual other than zero means the ledger is inconsistent.
200429
6 operations
/api/v1/ratesPublicExchange rates on file
Point-in-time facts, never updated. A lookup asks for the most recent rate at or before a date, so re-running last quarter’s reports uses last quarter’s rates.
200429
/api/v1/ratesBearer tokenRecord a rate
Re-recording the same pair, day and source is a correction rather than a second opinion. A rate is a decimal string with up to ten places — never a JSON number, which would already have lost precision.
201400401422429
/api/v1/periods/{periodMonth}/revalueBearer tokenRetranslate foreign monetary balances at the closing rate
IAS 21 remeasurement: cash, receivables and payables held in a foreign currency are restated at the month-end rate and the difference goes to profit or loss. Inventory and fixed assets are not — they stay at the rate they were bought at. Cumulative rather than reversing, so running it twice posts nothing the second time. `?preview=true` computes the adjustment without posting it.
200400401409422429
/api/v1/periodsPublicMonths, and whether they still accept entries
A month with entries but no period row is open; the row is created when it is closed.
200429
/api/v1/periods/{periodMonth}/closeBearer tokenClose a month
Posts a closing entry that zeroes revenue and expense into retained earnings, then locks the month against any entry dated inside it. The closing entry goes through the journal like any other, so it obeys the same balance rule. Periods close in order, and the current month cannot be closed.
200400401409422429
/api/v1/periods/{periodMonth}/reopenBearer tokenReopen a closed month
Reverses the closing entry, dated inside the month rather than today, and unlocks it. The original close stays on the record.
200400401409429
3 operations
/api/v1/api-keysBearer tokenList this tenant’s keys
Digests are never returned. Each key carries an identifying prefix and the time it was last used, which is what makes revoking the right one possible.
200401429
/api/v1/api-keysBearer tokenIssue a key
Returns the token exactly once; only its SHA-256 digest is stored. Minting a key requires an existing key, so the first one comes from the seed rather than from an open endpoint.
201400401429
/api/v1/api-keys/{keyId}Bearer tokenRevoke a key
The row is kept and `revoked_at` is set, because a deleted row answers "who had access, and until when?" with silence. Revoking twice is a 404, not a silent success.
200401404429
7 operations
/api/v1/webhook-endpointsPublicList registered endpoints
Signing secrets are never included; they are returned once, at creation.
200429
/api/v1/webhook-endpointsBearer tokenRegister an endpoint
Returns the signing secret exactly once. Store it: it cannot be read back, only rotated. An empty eventTypes subscribes to everything.
201400401409429
/api/v1/webhook-endpoints/{endpointId}PublicFetch one endpoint
200404429
/api/v1/webhook-endpoints/{endpointId}Bearer tokenEnable or disable an endpoint
Re-enabling clears the consecutive-failure count, so the circuit breaker does not trip again on the next single failure.
200400401404429
/api/v1/webhook-endpoints/{endpointId}Bearer tokenRemove an endpoint and its delivery history
204401404429
/api/v1/webhook-deliveriesPublicThe delivery log
Every attempt, with its status code, response excerpt and next retry time — so a subscriber can diagnose its own failures without a support thread.
200400429
/api/v1/webhook-deliveries/{deliveryId}/replayBearer tokenQueue the same event again
Creates a new delivery rather than resetting the old one, so the record of the original failure survives. The payload carries a replayOf link for deduplication.
202401404429