# BoringBooks API > The BoringBooks accounting API is a catalog of **commands** (`POST /api/v1/commands/`) that change the ledger and **queries** (`POST /api/v1/queries/`) that read it. Every request and response is JSON; every request carries `Authorization: Bearer bb_…` — see Getting a token below. Version 0.1.0. Base URL: `https://boringbooks.io`. Most operations act on one ledger and take its `ledger_id`. Start with the `tenant` query, which says which company your token acts in and whether it is a sandbox, then the `ledgers` query, which lists the ledgers your token can reach. A company with more than one entity needs every request to say which one it acts for: send the entity's id, from the `ledgers` query's `entity.id`, in a `BoringBooks-Entity` header. Without it such a request is refused (`validation_error` / `invalid_input`); an id that is not one of the company's entities is `not_found`. A company with a single entity needs no header. This document is the contract for the two things most expensive to change later: how money is represented, and which date means what. **For AI agents:** this reference is also published as Markdown. [`/llms.txt`](/llms.txt) indexes every operation, [`/llms-full.txt`](/llms-full.txt) holds the whole reference in one file, and each operation has its own page at `/docs/commands/.md` or `/docs/queries/.md`. ### Getting a token API access is delegated: a person signs in, picks the company to connect, and approves an agent — or the BoringBooks CLI — to act for them there. Tokens are issued over OAuth 2.1 Authorization Code with PKCE, so there is no API key to create and no client secret to hold. - **With the BoringBooks CLI.** It runs the flow and keeps the token fresh; it is one file and needs Node.js 18 or later. Download it from this server's `/cli/bb.mjs`, then: node bb.mjs login and send each request with the access token `node bb.mjs token` prints. `login --paste` covers a person whose browser is on another machine. - **By hand.** Send the person to `GET /oauth/authorize` with `client_id=boringbooks-cli` and an S256 `code_challenge`, then exchange the returned code at `POST /oauth/token`. The step-by-step flow is in the Authentication section of [`/llms-full.txt`](/llms-full.txt), and `GET /.well-known/oauth-authorization-server` describes the endpoints machine-readably. - **Lifetimes.** An access token (`bb_…`) lasts one hour. Refresh it with the `bbr_…` refresh token, which rotates on every use and stays valid for 30 days from its last use, 90 days at most. - **What a token may do.** A token acts as a delegated agent: it reads what its role allows and can draft and submit journals, but never approve or post them — `approve_journal` answers `403`. - **Revoking.** `POST /oauth/revoke` revokes a token or refresh token; a person can also disconnect an agent from the connected-agents page in the web app. ### Sandboxes A sandbox is a company for test data. Every account starts with one, and a person can create more in the web app. Connect an agent to a sandbox by picking it on the approval screen, where it is marked **Sandbox**. Nothing it does there touches real books. - **Telling them apart.** A sandbox's tokens start with `test_bb_` and `test_bbr_` instead of `bb_` and `bbr_`. Every response to an authenticated request carries a `BoringBooks-Mode` header, `live` or `test`, and the `tenant` query returns the same value as `mode`. Trust the header or the query rather than the prefix. - **Same rules.** A sandbox behaves exactly like a live company: the same operations, validation, rate limits, and journal approval. Anything that works in a sandbox works the same way in a live company. - **Permanent.** A company's mode is fixed when it is created. A sandbox never becomes a live company, and its entries never move into one. ### Monetary amounts - Every monetary value is a **JSON string** holding a base-10 decimal — `"1000.00"`, `"-42.50"`, `"0.00"`. Never a JSON number: binary floating point cannot represent `0.10`, and a ledger cannot afford the drift. - **Scale is fixed by the currency.** Most supported currencies carry **exactly two** fraction digits, on input and on output; JPY carries **none**. On input, `"100"` and `"100.0"` are accepted and normalized to the currency's scale (`"100.00"`, or `"100"` for JPY); more precision than the currency admits (`"100.005"` for USD, `"100.5"` for JPY) is **rejected** (`validation_error` / `invalid_input`) — never rounded. - **No implicit rounding.** The API does not round a value you send to make it fit. Any rounding of a *computed* figure — a report total — is half away from zero, and applies only where a sum could carry more precision than its currency admits. - **Sign.** - A journal line on **input** carries an unsigned `amount` (greater than zero) plus a `side` of `"debit"` or `"credit"`. - A journal or ledger line on **output** carries `debit` and `credit`, each non-negative, with `"0.00"` on the side the line is not on. - **Report figures** read in their natural direction: assets, liabilities, equity, revenue, and expenses are positive, and a figure is negative only where the statement itself shows one — a net loss, a contra account, an overdrawn balance. The trial balance's `closing_net` and the balance sheet agree in sign for the same account. - An amount must be smaller than 10^22 in magnitude; a larger value is rejected. ### Currency - Each ledger records **one** currency: its entity's functional currency, chosen when the entity is set up and fixed once the entity has any journal. This is not multi-currency — a ledger never holds amounts in more than one currency, and every monetary request and response carries that single `currency` at the top level of the body (a journal's header, a report's result) rather than repeated per line or per row. - The supported set is exactly seven codes today: `AUD`, `CAD`, `CHF`, `EUR`, `GBP`, `JPY`, `USD`. The request schema (`CurrencyInput`) declares this set as an enum. A code outside it is **rejected** at the boundary (`validation_error` / `invalid_input`). - On a journal, `currency` is **optional** and defaults to the ledger's functional currency. A request naming a *different* currency from that set — one that is supported but not this ledger's — is **rejected** (`business_rule_violation` / `foreign_currency_not_supported`); an unsupported code is still the `invalid_input` case above. The two are distinguishable by their HTTP status: `400` for a malformed request, `422` for a well-formed one this ledger cannot honor. - A response's `currency` names the ledger's own currency. The response schema (`Currency`) is a plain string and deliberately does not declare a set: the supported set is additive, so a client must not treat what it receives here as closed — see Compatibility below. ### Dates and times There are two kinds of temporal value, and they are not interchangeable. - **Accounting dates** — a journal's `posting_date`, a period's `start_date` and `end_date` — are calendar dates, `YYYY-MM-DD`, with **no time and no timezone**. A journal's `posting_date` is the date it becomes effective in the ledger and is the only input to which fiscal period it belongs to (`start_date <= posting_date <= end_date`, both bounds inclusive — January is `2026-01-01` to `2026-01-31`). You choose it; it is never inferred from the current time, and a future date in an open period is allowed. - **Report dates** — `from_date`, `to_date`, and `as_of_date` on `income_statement`, `balance_sheet`, and `trial_balance` — are accounting dates too, and **inclusive** on both ends: `from_date` and `to_date` both count, and `as_of_date` includes that day's postings. A report can cover a full year, a single day, or stop part-way through a period. A range that ends before it starts is rejected (`validation_error` / `invalid_input`). - **System timestamps** — `submitted_at`, `expires_at`, an audit entry's `at` — are **RFC 3339, UTC, with a `Z` offset**, at second precision (`2026-09-08T14:03:22Z`). Output is always UTC. On input, an explicit offset is required; a timestamp with no offset is rejected. A journal has exactly one `posting_date`, and every line inherits it. Only journals are accounting-dated: every other command takes effect when it is accepted, and is recorded with a system timestamp in the audit trail. Posting into a closed period fails (`period_closed`); into a locked period, permanently (`period_locked`); into a date no period covers (`invalid_input`). ### Errors Every error response is a single `error` object — `type`, `code`, `message`, and a contextual `details` that is not part of the contract. The `ErrorResponse` schema lists every `type`/`code` pair and its status. Each part has one job: - **Decide with `type` and `code`.** Branch on `type` (a closed set); `code` refines it. - **Show `message`.** One or two sentences written for a person: what went wrong, naming accounts by code and name and periods by their dates, and what to do about it. A command that is the fix is named in backticks, like `reopen_period`. An agent can pass the message to its human as it stands. Its wording is not stable, so never parse it. - **Act on `details`.** The values a next call needs, under the names the rest of the API uses — `account_id`, `period_id`, `line_no`, `journal_ids` — so a client never has to pull an id out of the message. ```json {"error": { "type": "business_rule_violation", "code": "period_closed", "message": "January 2026 (2026-01-01 to 2026-01-31) is closed, so nothing can post into it. Date the entry in an open period, or reopen the period with `reopen_period` first.", "details": {"period_id": "d821fd35-2ffd-4c47-8668-09c3758b8a85", "period_start": "2026-01-01", "period_end": "2026-01-31", "status": "closed", "reason": "period_not_open"} }} ``` ### Rate limits Each tenant has two request budgets, one for commands and one for queries, so heavy reading never blocks posting. Every API token of a tenant draws on the same budgets. A request over its budget is refused with `429` (`rate_limited` / `rate_limit_exceeded`). Once one address fails authentication too often, requests from it are refused the same way until the window passes, unless their token has made a successful request within the last minute. When the server is at capacity, it sheds requests with `503` (`service_unavailable` / `server_overloaded`). Both responses carry a `Retry-After` header in whole seconds, and both are sent before the operation runs, so retrying after the delay is safe for a command too. Wait at least `Retry-After` before retrying, and back off further if the refusals continue. ### Idempotency A command may carry an `Idempotency-Key` header so a retry cannot run it twice. Queries ignore the header. - **Key format.** A UUID in its canonical 36-character form, `3f7c1e28-9a4b-4d5e-8c02-6b1f9a3d7e54`. Anything else is rejected (`validation_error` / `invalid_input`). Generate one key per *intent*, not per attempt: sending the same value on every retry of one call is what makes the retry safe. - **Scope.** A key is namespaced to your organization, and belongs to exactly one command and one request body. - **Replay.** Once a command has committed under a key, that key replays the original result and events for **at least 24 hours**, without running the command again. A replay is not always byte-identical to the first response: a value a command discloses exactly once is absent from it. Every such field says so in its schema description. A replay confirms the command ran; it is not a second chance to read a secret. - **A failed command does not consume its key.** A command's writes and its audit record commit in one transaction, so a failure leaves nothing behind and a retry with the same key runs the command again. This is deliberate: a retry of a command that failed with `period_closed` succeeds once the period is reopened. - **Reuse with a different request.** A key already spent by a *different command* is a `conflict` (`idempotency_conflict`). Reusing one against the same command with a *different body* is a client error that the API does not yet detect in every case, and may replay the original result instead — do not rely on that, it will become an `idempotency_conflict`. - **Concurrent duplicates.** Two requests carrying one key resolve to a single execution: one commits and the other replays it. Neither runs the command twice. - **A conflict is permanent.** A key is spent once and stays spent, so retrying an `idempotency_conflict` with the same key can never succeed. Query the ledger to see whether the call you meant has already taken effect; if it has not, send it under a new key. ### Compatibility `/api/v1` is the compatibility contract: the path segment changes only for a change that cannot be made additively. The OpenAPI document's `info.version` identifies the build that produced the document, not the API. **Added at any time, without notice:** - a field on a response - an optional field on a request - a command, a query, or an error `code` - a key under an error's `details` **So a client must:** - **Ignore response fields it does not recognize.** Do not treat the published set as closed and do not fail on an unexpected member. Response schemas in this document never close their property set, so a client generated from it already tolerates one. - **Branch on an error's `type`, not on its `code`.** `type` is a closed set and is declared as an enum; `code` refines it, grows over time, and is declared as a plain string for exactly that reason. An unrecognized `code` falls back to its `type`. - **Treat `message` and `details` as diagnostics.** Neither is machine-stable. **Requests are strict, and deliberately so.** An unknown field in a request body is rejected (`validation_error` / `invalid_input`) rather than ignored. A misspelled field name is a value that would otherwise be dropped silently — in a ledger, an amount or a date that never arrived — so the request is refused instead of quietly reinterpreted. Tolerating what you do not recognize is a rule for reading responses; it is not how this API reads requests. **Not done inside `/v1`:** - removing or renaming a response field, a command, a query, or an error `type` - changing the type, format, or meaning of an existing field - adding a required request field, or narrowing what an existing one accepts - adding a value to `type`, or to any enum a response is documented to return What `/v1` publishes stays in `/v1`. Where a shape turns out to be wrong, the replacement is added beside it and the original is marked deprecated rather than removed. The single reservation is a change compelled by a security or legal obligation, made with as much notice as that obligation allows. **Deprecation is signalled in the document.** A deprecated operation or field carries `deprecated: true`, and its description names what replaces it. A deprecated thing keeps working for the life of `/v1` — the marking says what will not carry into a later major version, not what is about to disappear. A generated client surfaces these as deprecation warnings, so diffing the published document is enough to find them. ## Authentication Every request acts as one principal — a human or an agent — in one tenant, with a bearer token: Authorization: Bearer bb_... A sandbox's tokens start with `test_bb_` instead (see Sandboxes above), and every authenticated response carries `BoringBooks-Mode: live` or `test`. Ask the human to connect you to a sandbox first while you are trying things out. An agent gets its token by asking a human to approve it, over OAuth 2.1 Authorization Code with PKCE (RFC 7636). There is no password or client secret to hold: PKCE proves the code exchange came from the same process that started it. ### With the BoringBooks CLI The BoringBooks CLI does the whole OAuth flow and keeps the token fresh. It is one file and needs Node.js 18 or later: mkdir -p ~/.config/boringbooks curl -fsSL https://boringbooks.io/cli/bb.mjs -o ~/.config/boringbooks/bb.mjs node ~/.config/boringbooks/bb.mjs login `login` opens the human's browser, where they pick a company and approve; it waits up to 10 minutes, so run it with a timeout at least that long or in the background. If the human's browser is on another machine (a hosted sandbox, SSH), run `login --paste` instead: it prints a link and exits. Give the link to the human, who approves and gets a one-time code, then run `login --code `. Then send every request with a token from `token`, which prints a valid access token and refreshes it when needed. Call it for each request rather than keeping the token yourself; it is safe to run concurrently: curl -H "Authorization: Bearer $(node ~/.config/boringbooks/bb.mjs token)" ... If `token` says the connection expired or was revoked, run `login` again. ### By hand Without Node, or without a shell at all, run the flow yourself: 1. Generate a `code_verifier` (43-128 characters from `[A-Za-z0-9-._~]`) and its S256 `code_challenge`: `BASE64URL-NOPAD(SHA256(code_verifier))`. Keep the verifier secret until step 5. 2. Pick a `redirect_uri`: - If the human's browser runs on the machine you run on, start a loopback HTTP listener at `http://127.0.0.1:/callback` or `http://[::1]:/callback`. Any port works; `localhost` does not, use the literal address. - Otherwise (a hosted sandbox, a remote machine), use exactly `https://boringbooks.io/oauth/code`. After approval that page shows the code for the human to paste back to you. Such a code lives 10 minutes instead of 60 seconds. 3. Give the human the authorization URL to open (or open it for them): GET https://boringbooks.io/oauth/authorize ?client_id=boringbooks-cli &redirect_uri= &response_type=code &code_challenge= &code_challenge_method=S256 &state= 4. The human picks which company to connect and approves or denies. With a loopback listener, approval redirects the browser to it with `?code=...&state=...&iss=...` and denial with `?error=access_denied&state=...&iss=...`; check `state` and `iss`. With `https://boringbooks.io/oauth/code`, ask the human to paste the code the page shows. 5. Exchange the code for tokens, with the same `redirect_uri` (URL-encoded): POST https://boringbooks.io/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=&redirect_uri=&client_id=boringbooks-cli&code_verifier= Response: { "access_token": "bb_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "bbr_...", "scope": "api" } 6. Use `access_token` as the bearer token above; it expires in `expires_in` seconds (one hour). Refresh before then — and again after every use, since refreshing rotates the refresh token and the previous one stops working: POST https://boringbooks.io/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token&refresh_token=&client_id=boringbooks-cli A refresh token is good for 30 days from its last use, and 90 days from first approval, whichever comes first. An agent acts as a delegated agent principal: it can propose and submit its own draft journals and read everything its role allows, but it can never approve or post a journal, no matter what role the approving human holds — `approve_journal` always answers `403` for an agent. `GET /.well-known/oauth-authorization-server` (RFC 8414) and `GET /.well-known/oauth-protected-resource` (RFC 9728) describe this flow's endpoints and scopes machine-readably, and a `401` response's `WWW-Authenticate` header points at the protected-resource document. ## Commands ### apply_chart_template `POST /api/v1/commands/apply_chart_template` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `ledger_id` | string (uuid) | yes | | | `template` | string | yes | One of: `minimal`. | Example: ```json { "ledger_id": "11111111-1111-4111-8111-111111111111", "template": "minimal" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `count` | integer | yes | | | `ledger_id` | string (uuid) | yes | | ### approve_journal `POST /api/v1/commands/approve_journal` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "44444444-4444-4444-8444-444444444444" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### archive_account `POST /api/v1/commands/archive_account` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "22222222-2222-4222-8222-222222222222" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `active` | boolean | yes | | | `id` | string (uuid) | yes | | ### close_period `POST /api/v1/commands/close_period` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `ledger_id` | string (uuid) | yes | | | `period_id` | string (uuid) | yes | | | `retained_earnings_account_id` | string (uuid) | yes | | Example: ```json { "ledger_id": "11111111-1111-4111-8111-111111111111", "period_id": "55555555-5555-4555-8555-555555555555", "retained_earnings_account_id": "88888888-8888-4888-8888-888888888888" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `status` | string | yes | | | `journal_id` | string (uuid), nullable | no | | ### create_account `POST /api/v1/commands/create_account` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `code` | string | yes | Account code, unique per ledger Min length: `1`. Max length: `255`. | | `ledger_id` | string (uuid) | yes | External id of the ledger this account belongs to | | `name` | string | yes | Min length: `1`. Max length: `255`. | | `type` | string | yes | One of the five account types One of: `asset`, `liability`, `equity`, `revenue`, `expense`. | | `contra` | boolean | no | Balance runs against the type's normal side Default: `false`. | | `postable` | boolean | no | A leaf that journals may post to Default: `true`. | Example: ```json { "code": "1000", "ledger_id": "11111111-1111-4111-8111-111111111111", "name": "Cash", "type": "asset" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `code` | string | yes | | | `id` | string (uuid) | yes | External id of the new account | | `name` | string | yes | | | `type` | string | yes | | ### create_dimension_definition `POST /api/v1/commands/create_dimension_definition` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `key` | string | yes | The dimension's name, unique within the ledger, with no leading or trailing spaces Min length: `1`. Max length: `255`. | | `ledger_id` | string (uuid) | yes | | | `value_policy` | object, nullable | no | The values a line may carry for this dimension, as `{"values": ["sales", "ops"]}`. Omit it, or send `null`, to accept any value. | | `value_policy.values` | array of string | yes | Distinct values with no leading or trailing spaces Min items: `1`. | Example: ```json { "key": "department", "ledger_id": "11111111-1111-4111-8111-111111111111" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `key` | string | yes | | ### create_draft_journal `POST /api/v1/commands/create_draft_journal` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `ledger_id` | string (uuid) | yes | | | `lines` | array of object | yes | Min items: `1`. Max items: `100`. | | `lines[].account_id` | string (uuid) | yes | | | `lines[].amount` | `AmountInput` | yes | | | `lines[].side` | string | yes | One of: `debit`, `credit`. | | `lines[].dimensions` | object, nullable | no | | | `lines[].memo` | string, nullable | no | Max length: `4000`. | | `posting_date` | string (date) | yes | | | `currency` | `CurrencyInput` | no | | | `description` | string, nullable | no | Max length: `4000`. | | `proposal_kind` | string | no | Where the proposal came from, as a short lowercase label: `manual` (the default), or your own, such as `import` or `bank_feed`. `system` is reserved for the journals the ledger writes itself, closing entries and reversals. Default: `"manual"`. Max length: `64`. | | `rationale` | string, nullable | no | Max length: `4000`. | Example: ```json { "description": "Owner's initial investment", "ledger_id": "11111111-1111-4111-8111-111111111111", "lines": [ { "account_id": "22222222-2222-4222-8222-222222222222", "amount": "1000.00", "side": "debit" }, { "account_id": "33333333-3333-4333-8333-333333333333", "amount": "1000.00", "side": "credit" } ], "posting_date": "2026-01-15" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### create_fiscal_year `POST /api/v1/commands/create_fiscal_year` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `ledger_id` | string (uuid) | yes | | | `year` | integer | yes | Min: `1970`. Max: `9999`. | Example: ```json { "ledger_id": "11111111-1111-4111-8111-111111111111", "year": 2026 } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `count` | integer | yes | | | `year` | integer | yes | | ### discard_draft `POST /api/v1/commands/discard_draft` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "44444444-4444-4444-8444-444444444444" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### lock_period `POST /api/v1/commands/lock_period` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "55555555-5555-4555-8555-555555555555" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### post_journal `POST /api/v1/commands/post_journal` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "44444444-4444-4444-8444-444444444444" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### post_journals_batch `POST /api/v1/commands/post_journals_batch` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `ids` | array of string (uuid) | yes | Min items: `1`. Max items: `500`. | Example: ```json { "ids": [ "44444444-4444-4444-8444-444444444444", "45444444-4444-4444-8444-444444444444" ] } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `count` | integer | yes | | | `posted` | array of string (uuid) | yes | | ### reactivate_account `POST /api/v1/commands/reactivate_account` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "22222222-2222-4222-8222-222222222222" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `active` | boolean | yes | | | `id` | string (uuid) | yes | | ### reject_draft `POST /api/v1/commands/reject_draft` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `reason` | string, nullable | no | Max length: `4000`. | Example: ```json { "id": "44444444-4444-4444-8444-444444444444", "reason": "Missing supporting document" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### reopen_period `POST /api/v1/commands/reopen_period` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "55555555-5555-4555-8555-555555555555" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### return_to_draft `POST /api/v1/commands/return_to_draft` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `note` | string | yes | Min length: `1`. Max length: `4000`. | Example: ```json { "id": "44444444-4444-4444-8444-444444444444", "note": "Wrong account on line 2 — should be 6100, not 6000" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### reverse_journal `POST /api/v1/commands/reverse_journal` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `posting_date` | string (date) | yes | | Example: ```json { "id": "44444444-4444-4444-8444-444444444444", "posting_date": "2026-02-01" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `reverses` | string (uuid) | yes | | | `status` | string | yes | | ### submit_draft `POST /api/v1/commands/submit_draft` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "44444444-4444-4444-8444-444444444444" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ### update_account_metadata `POST /api/v1/commands/update_account_metadata` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `dimension_rules` | object, nullable | no | Which dimensions a line on this account must or may carry, keyed by a dimension the ledger defines: `{"department": "required"}`. A dimension left out may not be used on the account. Send `{}` or `null` to lift every restriction. | | `name` | string | no | Min length: `1`. Max length: `255`. | Example: ```json { "id": "22222222-2222-4222-8222-222222222222", "name": "Cash and cash equivalents" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `name` | string | yes | | ### update_draft_journal `POST /api/v1/commands/update_draft_journal` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `currency` | `CurrencyInput` | no | | | `description` | string, nullable | no | Max length: `4000`. | | `lines` | array of object | no | Min items: `1`. Max items: `100`. | | `lines[].account_id` | string (uuid) | yes | | | `lines[].amount` | `AmountInput` | yes | | | `lines[].side` | string | yes | One of: `debit`, `credit`. | | `lines[].dimensions` | object, nullable | no | | | `lines[].memo` | string, nullable | no | Max length: `4000`. | | `posting_date` | string (date) | no | | | `rationale` | string, nullable | no | Max length: `4000`. | Example: ```json { "description": "Corrected description", "id": "44444444-4444-4444-8444-444444444444" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `status` | string | yes | | ## Queries ### account_ledger `POST /api/v1/queries/account_ledger` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `account_id` | string (uuid) | yes | | | `cursor` | string, nullable | no | | | `limit` | integer | no | Min: `1`. Max: `200`. | Example: ```json { "account_id": "22222222-2222-4222-8222-222222222222", "limit": 50 } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `currency` | `Currency` | yes | | | `has_more` | boolean | yes | | | `lines` | array of object | yes | | | `lines[].credit` | `Amount` | yes | | | `lines[].debit` | `Amount` | yes | | | `lines[].dimensions` | object, nullable | no | | | `lines[].journal_id` | string (uuid) | no | | | `lines[].line_no` | integer | no | | | `lines[].memo` | string, nullable | no | | | `lines[].posting_date` | string (date) | no | | | `next_cursor` | string, nullable | yes | | | `total_count` | integer | yes | | | `total_count_exact` | boolean | yes | | ### attention_queue `POST /api/v1/queries/attention_queue` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `cursor` | string, nullable | no | | | `limit` | integer | no | Min: `1`. Max: `200`. | Example: ```json {} ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `has_more` | boolean | yes | | | `items` | array of object | yes | | | `items[].actions` | array of `Action` | no | | | `items[].currency` | `Currency` | no | | | `items[].description` | string, nullable | no | | | `items[].id` | string (uuid) | no | | | `items[].posting_date` | string (date) | no | | | `items[].proposed_by` | object | no | | | `items[].proposed_by.id` | string (uuid) | no | | | `items[].proposed_by.kind` | string | no | | | `items[].proposed_by.name` | string | no | | | `items[].rationale` | string, nullable | no | | | `items[].status` | string | no | | | `items[].submitted_at` | string (date-time), nullable | no | | | `items[].total` | `Amount` | no | | | `next_cursor` | string, nullable | yes | | | `total_count` | integer | yes | | | `total_count_exact` | boolean | yes | | ### audit_trail `POST /api/v1/queries/audit_trail` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `principal_id` | string (uuid) | yes | | | `cursor` | string, nullable | no | | | `limit` | integer | no | Min: `1`. Max: `200`. | Example: ```json { "limit": 50, "principal_id": "77777777-7777-4777-8777-777777777777" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `entries` | array of object | yes | | | `entries[].at` | string (date-time) | no | | | `entries[].command` | string | no | | | `entries[].id` | string (uuid) | no | | | `entries[].status` | string | no | | | `has_more` | boolean | yes | | | `next_cursor` | string, nullable | yes | | | `total_count` | integer | yes | | | `total_count_exact` | boolean | yes | | ### balance_sheet `POST /api/v1/queries/balance_sheet` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `as_of_date` | string (date) | yes | The date the balances are stated at, including that day's postings. | | `ledger_id` | string (uuid) | yes | | Example: ```json { "as_of_date": "2026-09-15", "ledger_id": "11111111-1111-4111-8111-111111111111" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `as_of_date` | string (date) | yes | | | `assets` | `Amount` | yes | | | `currency` | `Currency` | yes | | | `current_earnings` | `Amount` | yes | | | `equity` | `Amount` | yes | | | `liabilities` | `Amount` | yes | | | `rows` | array of object | yes | | | `rows[].account_id` | string (uuid) | yes | | | `rows[].balance` | `Amount` | yes | | | `rows[].code` | string | yes | | | `rows[].name` | string | yes | | | `rows[].type` | string | yes | | ### chart_of_accounts `POST /api/v1/queries/chart_of_accounts` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `ledger_id` | string (uuid) | yes | | Example: ```json { "ledger_id": "11111111-1111-4111-8111-111111111111" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `accounts` | array of object | yes | | | `accounts[].active` | boolean | no | | | `accounts[].code` | string | no | | | `accounts[].contra` | boolean | no | | | `accounts[].id` | string (uuid) | no | | | `accounts[].name` | string | no | | | `accounts[].postable` | boolean | no | | | `accounts[].type` | string | no | | | `currency` | `Currency` | yes | | ### income_statement `POST /api/v1/queries/income_statement` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `from_date` | string (date) | yes | The first date included in the statement. | | `ledger_id` | string (uuid) | yes | | | `to_date` | string (date) | yes | The last date included in the statement. Must be on or after `from_date`. | Example: ```json { "from_date": "2026-01-01", "ledger_id": "11111111-1111-4111-8111-111111111111", "to_date": "2026-09-15" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `currency` | `Currency` | yes | | | `expenses` | `Amount` | yes | | | `from_date` | string (date) | yes | | | `net_income` | `Amount` | yes | | | `revenue` | `Amount` | yes | | | `rows` | array of object | yes | | | `rows[].account_id` | string (uuid) | yes | | | `rows[].amount` | `Amount` | yes | | | `rows[].code` | string | yes | | | `rows[].name` | string | yes | | | `rows[].type` | string | yes | | | `to_date` | string (date) | yes | | ### journal_detail `POST /api/v1/queries/journal_detail` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | Example: ```json { "id": "44444444-4444-4444-8444-444444444444" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `currency` | `Currency` | yes | | | `id` | string (uuid) | yes | | | `lines` | array of object | yes | | | `lines[].credit` | `Amount` | yes | | | `lines[].debit` | `Amount` | yes | | | `lines[].account_code` | string | no | | | `lines[].account_id` | string (uuid) | no | | | `lines[].dimensions` | object, nullable | no | | | `lines[].line_no` | integer | no | | | `lines[].memo` | string, nullable | no | | | `status` | string | yes | | | `timeline` | array of object | yes | | | `timeline[].at` | string (date-time) | yes | | | `timeline[].type` | string | yes | | | `timeline[].by` | string (uuid), nullable | no | | | `timeline[].status` | string, nullable | no | | | `actions` | array of `Action` | no | | | `description` | string, nullable | no | | | `evidence` | object, nullable | no | | | `posting_date` | string (date) | no | | | `proposal_kind` | string, nullable | no | | | `proposed_by` | object | no | | | `proposed_by.id` | string (uuid) | no | | | `proposed_by.kind` | string | no | | | `proposed_by.name` | string | no | | | `rationale` | string, nullable | no | | | `rejection_reason` | string, nullable | no | | | `review_note` | string, nullable | no | | | `submitted_at` | string (date-time), nullable | no | | | `total` | `Amount` | no | | ### ledgers `POST /api/v1/queries/ledgers` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `cursor` | string, nullable | no | | | `limit` | integer | no | Min: `1`. Max: `200`. | Example: ```json {} ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `has_more` | boolean | yes | | | `ledgers` | array of object | yes | | | `ledgers[].currency` | `Currency` | yes | | | `ledgers[].entity` | object | yes | | | `ledgers[].entity.id` | string (uuid) | yes | | | `ledgers[].entity.name` | string | yes | | | `ledgers[].id` | string (uuid) | yes | | | `ledgers[].name` | string | yes | | | `next_cursor` | string, nullable | yes | | | `total_count` | integer | yes | | | `total_count_exact` | boolean | yes | | ### period_status `POST /api/v1/queries/period_status` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `ledger_id` | string (uuid) | yes | | Example: ```json { "ledger_id": "11111111-1111-4111-8111-111111111111" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `periods` | array of object | yes | | | `periods[].end_date` | string (date) | yes | | | `periods[].id` | string (uuid) | yes | | | `periods[].start_date` | string (date) | yes | | | `periods[].status` | string | yes | | ### returned_drafts `POST /api/v1/queries/returned_drafts` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `cursor` | string, nullable | no | | | `limit` | integer | no | Min: `1`. Max: `200`. | Example: ```json {} ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `has_more` | boolean | yes | | | `items` | array of object | yes | | | `items[].description` | string, nullable | no | | | `items[].id` | string (uuid) | no | | | `items[].posting_date` | string (date) | no | | | `items[].proposed_by` | object | no | | | `items[].proposed_by.id` | string (uuid) | no | | | `items[].proposed_by.name` | string | no | | | `items[].returned_at` | string (date-time) | no | | | `items[].review_note` | string, nullable | no | | | `next_cursor` | string, nullable | yes | | | `total_count` | integer | yes | | | `total_count_exact` | boolean | yes | | ### tenant `POST /api/v1/queries/tenant` #### Request body Type: object Example: ```json {} ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `mode` | string | yes | `live` for a company's real books, `test` for a sandbox. Fixed when the company is created. A sandbox follows the same rules as a live company, journal approval included. Treat any other value as not live. | | `name` | string | yes | | ### trial_balance `POST /api/v1/queries/trial_balance` #### Request body | Field | Type | Required | Description | |---|---|---|---| | `as_of_date` | string (date) | yes | The date the balances are stated at, including that day's postings. | | `ledger_id` | string (uuid) | yes | | | `from_date` | string (date) | no | Optional. The first date of the activity columns; adds `opening`, `period_debit`, and `period_credit` to every row. Must be on or before `as_of_date`. | Example: ```json { "as_of_date": "2026-09-15", "from_date": "2026-09-01", "ledger_id": "11111111-1111-4111-8111-111111111111" } ``` #### Response (200) | Field | Type | Required | Description | |---|---|---|---| | `as_of_date` | string (date) | yes | | | `currency` | `Currency` | yes | | | `rows` | array of object | yes | | | `rows[].account_id` | string (uuid) | yes | | | `rows[].closing_net` | `Amount` | yes | | | `rows[].code` | string | yes | | | `rows[].credit_balance` | `Amount` | yes | | | `rows[].debit_balance` | `Amount` | yes | | | `rows[].name` | string | yes | | | `rows[].type` | string | yes | | | `rows[].opening` | `Amount` | no | | | `rows[].period_credit` | `Amount` | no | | | `rows[].period_debit` | `Amount` | no | | | `total_credit_balance` | `Amount` | yes | | | `total_debit_balance` | `Amount` | yes | | | `from_date` | string (date) | no | | ## Shared types Fields typed with one of these names refer to it. - `Action` (object): One thing the caller may do next to a resource, filtered to what its own permissions and the resource's current state allow. A renderer draws its own control from `label` and `style`; it does not need to know the resource's status or the caller's role to decide whether to show it — an absent action means the caller may not take it right now. - `Amount` (string): A monetary amount as a decimal string, fixed to the currency's scale — two fraction digits for most supported currencies, none for JPY, e.g. "1000.00" or "1000" for JPY. Never a JSON number. Report figures may be negative; a journal line's `debit` and `credit` are always non-negative, with "0.00" ("0" for JPY) on the side the line is not on. - `AmountInput` (string): An unsigned decimal magnitude as a string, greater than zero, in plain notation: digits with an optional fractional part, and no sign, exponent, or separators. "100" and "100.0" are accepted and normalized to the currency's scale ("100.00" for most currencies, "100" for JPY); more precision than the currency admits ("100.005" for USD, "100.5" for JPY) is rejected, never rounded. - `Currency` (string): An ISO 4217 alphabetic currency code — a ledger's own functional currency. The supported set is additive, so do not treat it as closed. - `CurrencyInput` (string): An ISO 4217 alphabetic currency code from the supported set. It must be the target ledger's functional currency: a different supported currency is refused with `foreign_currency_not_supported`, and a currency outside the supported set is rejected outright. The supported set is additive and may grow. One of: `AUD`, `CAD`, `CHF`, `EUR`, `GBP`, `JPY`, `USD`. ## Errors Every error response carries a single `error` object. Branch on `type`; `code` refines it. `details` is contextual and not part of the contract. ```json {"error": {"type": "validation_error", "code": "invalid_input", "message": "…", "details": {}}} ``` `type` is one of: `authentication_error`, `authorization_error`, `validation_error`, `not_found`, `business_rule_violation`, `conflict`, `rate_limited`, `internal_error`, `service_unavailable`. The refining code. Additive: a new code under an existing `type` is not a breaking change. | code | type | status | meaning | |---|---|---|---| | `invalid_api_key` | `authentication_error` | 401 | The bearer token is missing, malformed, unknown, expired, or revoked. Every case is answered identically so nothing about which check failed leaks. | | `insufficient_permission` | `authorization_error` | 403 | The authenticated principal lacks the permission the operation requires. `details.permission` names it. | | `approval_policy_denied` | `authorization_error` | 403 | The command is well-formed and permitted, but an approval policy refused it — for example, the proposer of a journal may not approve it. | | `invalid_input` | `validation_error` | 400 | The request body failed validation: a missing or mistyped field, a value out of range, an unparseable cursor or timestamp, an unrecognized currency, an account or dimension the ledger does not hold, a posting date no fiscal period covers. When more than one field is at fault, `details.fields` lists each as `{name, message}`, carrying its own `details` — a line number, say — where it has any. | | `malformed_request` | `validation_error` | 400 | The framework rejected the request before the operation ran: a body that is not valid JSON, an unsupported `Content-Type`, a payload over the size limit. The status is the framework's own (400, 413, 415), and the request will not succeed on retry unchanged. | | `resource_not_found` | `not_found` | 404 | An id in the request does not resolve for the acting tenant — a resource that belongs to another tenant is reported the same way. `details.resource` names the kind and `details.id` echoes the id. Also returned for an unknown operation name. | | `business_rule_violation` | `business_rule_violation` | 422 | The request is structurally valid but violates an accounting rule that has no more specific code. `details.reason` carries the internal rule name, which is not part of the contract. | | `transaction_does_not_balance` | `business_rule_violation` | 422 | A journal's lines do not sum to zero in some currency. `details.currency`, `details.delta` (the signed residual), and `details.amount_field` say where. | | `account_inactive` | `business_rule_violation` | 422 | A journal line references an account that has been archived. Reactivate it with `reactivate_account` or post to a different account. | | `period_closed` | `business_rule_violation` | 422 | The posting date falls in a closed accounting period. A closed period can be reopened by an authorized principal. | | `period_locked` | `business_rule_violation` | 422 | The posting date falls in a locked accounting period. A locked period cannot be reopened; the entry must go to the current open period. | | `foreign_currency_not_supported` | `business_rule_violation` | 422 | The journal names a supported currency other than the ledger's functional currency. The ledger records only its own currency until multi-currency support lands. `details.currency` and `details.functional_currency` say which. | | `reversal_predates_original` | `business_rule_violation` | 422 | The reversal's `posting_date` falls before the posting date of the journal it reverses, so it would correct a period the original never touched. `details.original_posting_date` is the earliest date the reversal may take. | | `resource_conflict` | `conflict` | 409 | The target is not in the state the command requires — posting a journal that is not a draft, reopening a period that is not closed. `details.expected` and `details.actual` say which. | | `idempotency_conflict` | `conflict` | 409 | The `Idempotency-Key` was already spent by a different command, or a concurrent request carrying the same key is still in flight. | | `rate_limit_exceeded` | `rate_limited` | 429 | Too many requests. Retry after the delay in the `Retry-After` header. | | `internal_error` | `internal_error` | 500 | An unexpected server-side fault. The response carries only `details.incident_id`; no internal detail is echoed. Safe to retry with backoff. | | `server_overloaded` | `service_unavailable` | 503 | The server is at capacity and shed the request before running it. Nothing was executed. Retry after the delay in the `Retry-After` header. |