braatzbooks
Skip to content

API Docs

BraatzBooks API, version v1. 32 endpoints, listed below and machine-readable at /openapi.json (OpenAPI 3.1).

One list, no second

This page, /openapi.json and the MCP tool set are all generated from the same endpoint index the server serves at /api/v1. None of them can name an endpoint the others don't.

Authentication

Authorization: Bearer -- mint one at /settings (one year, read-only or read-write, revocable there); a browser session cookie from /signin also works

Writes

POST with Content-Type: application/json; a read-only token is refused (403); the same capability functions the rooms call, the actor recorded as human: (session) or token::

Endpoints

GET /api/v1/me

MCP tool: get_me

the books' summary: lines, booked, archived, pending (listed, not yet posted, in no total), total_cents, by_category, auth (which credential let you in), and the visible_from floor with vault_rows/visible_rows beside it -- every number here is over what this face SERVES, never over the whole vault

Parameters: —

GET /api/v1/visibility

MCP tool: get_visibility

the tenant's floor and what it subtracts: visible_from (stored at signup, never moves), tier, window_hours (the free tier's rolling window in hours -- 48 -- or null), effective_from (whichever floor subtracts more), oldest_visible_row, vault_rows (everything C holds) and visible_rows (what this face serves). The vault keeps the whole initial pull and nothing is deleted to shrink a view; an empty visible_from with a null window_hours means this tenant has no floor and sees all of it

Parameters: —

GET /api/v1/transactions

MCP tool: list_transactions

rows of C, paged; total_cents is over the WHOLE matched set, never the page; each row carries in_books, exclude_reason and state (in books | pending | superseded | retracted | archived) -- a pending line is listed and in no total

Parameters: from — ISO date, inclusive lower bound on posted_on
to — ISO date, inclusive upper bound
cat — exact category label; 'uncategorised' for the queue
q — substring of the description
page — 1-based
per_page — rows per page, capped at 100

GET /api/v1/transactions/{side}/{src_id}

MCP tool: get_transaction

one line, its REQ-8 lineage in words, and for an A line the provider's verbatim record (raw)

Parameters: side (in path) one of A, B — A = the feed's line, B = its archived twin
src_id (in path) — the line's id on that side

GET /api/v1/transactions/{side}/{src_id}/why

MCP tool: explain_transaction

why this line says what it says, in one templated sentence, with the citations it was built from -- which rule (and its revision), which model and how sure, which human and when, the duh band, and the pocket. citations is the evidence and sentence is only its rendering: a caller that wants to disagree should read the citations. engine names what decided the line; defect is non-empty when these books hold NO citation for it, which is a REPORTED gap and never a generated reason. 404 when this tenant holds no such line; 404 with a named hint when this deployment's tap does not request the read

Parameters: side (in path) one of A, B — A = the feed's line, B = its archived twin
src_id (in path) — the line's id on that side

POST /api/v1/transactions/{side}/{src_id}/label

MCP tool: label_transaction

"that's wrong" -- correct ONE line and have the correction round-trip to a labelled training row in the same breath. ONE ledger.labels row AND the set_category op are written as ONE database fact, so a label without a moved line cannot exist; the OLD category is derived from the ops ledger rather than taken from this caller, because asking the thing being corrected what it had wrong is how a training row gets a fictional "before". Returns the r_ops seq that MOVED the line. 409 on side B: an archived duplicate arrival carries no category to correct -- correct its original instead

Parameters: side (in path) one of A, B — A = the feed's line; B is 409
src_id (in path) — the line's id on that side
category required — the category the books SHOULD have given this line
reason — why, in the tenant's own words -- stored on the label row as provenance

GET /api/v1/accounts

MCP tool: list_accounts

the sub-accounts the feed has delivered lines for; pending is counted from the BOOKS (lines the feed's own judgement still holds as pending), so a line that has since posted is not in it

Parameters: —

GET /api/v1/sync-history

MCP tool: list_sync_history

bank-sync runs, newest first

Parameters: limit — runs to return, capped at 200

GET /api/v1/rules

MCP tool: list_rules

the live rulebook, retired revisions included, in the order it DECIDES: the bottom row wins when two rules match a line (priority descending -- the matcher's walk, reversed)

Parameters: —

GET /api/v1/exceptions

MCP tool: list_exceptions

lines no live rule and no live op has explained

Parameters: —

GET /api/v1/categories

MCP tool: list_categories

the tenant's own categories as the books use them, each with its booked total_cents and line count, most money first; 'uncategorised' is the queue (there is no house chart of accounts here: a label exists once a line carries it)

Parameters: —

GET /api/v1/taxonomy

MCP tool: list_category_taxonomy

the tenant's category TAXONOMY as the New category dialog defines it -- name, cat_key, header_key, header_name and the header's sort_order -- live revisions only (a retired category is one the books have stopped deriving, and offering it back is offering a word the taxonomy has retracted), ordered by header then name so it renders the same way twice. This is what create_category and create_categories write into, and it answers whether or not any line carries the label yet; list_categories is the BOOKS' aggregate and answers only labels lines actually carry, with their totals. 404 when this deployment's tap does not request the categories read

Parameters: —

GET /api/v1/usage

MCP tool: get_usage

the two meters a plan would bill on (inventory E4): bank connections in use against the cap this surface enforces, and transaction volume over a trailing window -- the LARGER of money in and money out, never their sum and never the net, which is the basis the meter measured on 2026-09-01 uses

Parameters: days — trailing window in days, default 30, capped at 365

GET /api/v1/meter

MCP tool: get_meter

the cost meter, one row per calendar month, newest first: calls (tail calls, abstentions included -- an unsure answer still crossed the wire and still cost money), millicents, unpriced_calls (calls whose cost was absent or unparseable, EXCLUDED from millicents and reported rather than dropped), lines_resolved_local against lines_sent_to_api (the ratio a plan is priced on), and the cap: cap_tier, cap_millicents_month, cap_source and the computed mode. mode is null when this vault knows no cap, interactive under it and batched at or over it -- null is NOT 'under the cap', because an unknown cap reported as healthy is the one lie this meter exists to refuse

Parameters: months — how many months back, newest first, default 36, capped at 120

GET /api/v1/messages

MCP tool: get_messages

this tenant's own thread with the business, newest first: direction (in what they texted, out what was texted back), channel, status, body, provider_sid (the carrier's own id for an out row, empty when there is not one yet) and created_at. DRAFTS ARE NOT HERE and cannot be: a reply is invisible until a human releases it, so this face can never show a customer words nobody chose to send. count is the number of rows IN THIS DOCUMENT and not the lifetime total, so a truncated read is distinguishable from a short thread by comparing it against the limit asked for

Parameters: limit — how many messages back, newest first, default 50; the ledger ceilings it at 200 in its own body, so a larger ask is clamped rather than honoured

GET /api/v1/reports/pnl-rolling

MCP tool: get_pnl_rolling

the Daily Rolling P&L as STRUCTURE, not prose: two windows (7d, 30d), each with the same six sections (revenue, cogs, gross_margin, biz_expenses, net_income, owners_draw) carrying current and prior, their category lines, and every booked transaction that contributed to each line. gross_margin and net_income are DERIVED (derived: true) and re-carry their components' lines with the sign each component enters with, so the twelve section current numbers are what a coefficient compares -- do not also sum the derived sections' lines. lineage_basis says whether the paper-verified lineage was readable (it is unreadable from this door: v_c_ledger does not carry reconciled_state, which changes no section number); lists_source names which category lists classified the rows

Parameters: tenant — must be the credential's own tenant; omit it and the credential decides
as_of — ISO date the two windows end on, in America/Chicago; default today

POST /api/v1/reports/pnl-parity

MCP tool: record_pnl_parity

grade the DIY n8n oracle's Daily Rolling P&L against BraatzBooks's own and file ONE deterministic verdict for the day: the difference coefficient over the twelve section numbers, the per-section deltas, and the localization of every delta down to the transactions that explain it (RECATEGORIZED / AMOUNT_DIFF / WINDOW_EDGE / MISSING_ON_BRAATZ / MISSING_ON_ORACLE). POST oracle alone and this surface computes ITS OWN side from its own books -- our numbers are not something a caller gets to supply. braatz may be sent anyway, and is then CONFIRMED rather than used: if its twelve section numbers disagree with what these books say, the request is 409 and nothing is filed. A second POST for the same day UPSERTS and returns a higher revision

Parameters: oracle required — the ORACLE's STRUCTURED report -- what GET /api/v1/reports/pnl-rolling emits in shape, computed by the n8n workflow. A rendered card or the n8n node output is 400: the comparator needs section -> category -> transaction
braatz — optional, and an ASSERTION rather than an input: this surface recomputes its own side and 409s on a disagreement
as_of — ISO date the windows end on; default the oracle report's own as_of, else today in America/Chicago

POST /api/v1/categorize

MCP tool: categorize

set one line's category -- an APPENDED judgement in the op ledger, never an edit; 'uncategorized' unsets; the archive side (B) is refused by the ledger

Parameters: side required one of A, B
src_id required — the line's id on that side
category required

POST /api/v1/adjudicate

MCP tool: adjudicate

resolve ONE exception and mint a versioned rule from it (REQ-8): the line gets its category and every future match is decided by the rule

Parameters: a_id required — the exception's A-side id
rule_key required — your name for the rule; a new revision if it exists
match_kind required one of description_exact, description_regex, amount_sign
match_value required
category required
priority — lower decides first; default 100

POST /api/v1/run-rules

MCP tool: run_rules

run every live rule over the uncategorised lines; returns how many it decided (a second run decides 0 -- idempotent by construction)

Parameters: —

POST /api/v1/move-rule-to-end

MCP tool: move_rule_to_end

make ONE rule the decider for every line it matches (fintable's 'move to end'): an APPENDED revision whose priority is below every other live rule's, never an edit; a rule already deciding appends nothing; touches no line already decided -- the next run-rules is where it shows

Parameters: rule_key required — the rule's key as list_rules shows it

POST /api/v1/retire-rule

MCP tool: retire_rule

switch ONE rule off (fintable's 'Delete this rule'): an APPENDED revision with retired=true, never a delete -- the transactions it already decided KEEP their categories, it decides no further lines, and the retired revision stays in list_rules so "we turned it off" never looks like "there was never a rule"

Parameters: rule_key required — the rule's key as list_rules shows it

POST /api/v1/new-category

MCP tool: create_category

add ONE category to the tenant's taxonomy under an existing header (fintable's New category dialog): an appended definer call, the ledger's own refusal relayed verbatim when the name already lives under another header; 404 when this deployment's tap does not request the verb

Parameters: name required — the category as the books will spell it
header required one of cogs, expense, income, draw, transfer — the header key it groups under

POST /api/v1/new-categories

MCP tool: create_categories

add SEVERAL categories in one call (fintable's create_bulk_categories): every row is validated BEFORE anything is written, then appended in order -- the response carries created (the rows that landed, with their ids) and stops at the first ledger refusal, which it names

Parameters: categories required — objects with name and header, as POST /api/v1/categories takes them, at most 100

POST /api/v1/create-rule

MCP tool: create_rule

file a rule with NO transaction to hang it on (fintable's Create rule; G1's ledger.create_rule): it decides every matching line nobody has already decided, and every future match -- born_from_a_id NULL, which adjudicate can never produce; an APPENDED revision when the key already exists, never an edit (ruling #135)

Parameters: rule_key required — your name for the rule; a new revision if it exists
match_kind required one of description_exact, description_regex, amount_sign
match_value required
category required
priority — lower decides first; default 100

POST /api/v1/create-rules

MCP tool: create_rules

file SEVERAL rules in one call (fintable's create_bulk_rules): every row is validated BEFORE anything is written, then appended in order -- the response carries created and stops at the first ledger refusal, which it names

Parameters: rules required — objects with rule_key, match_kind, match_value, category and optional priority, as POST /api/v1/rules takes them, at most 100

POST /api/v1/export

MCP tool: export_books

push the books to their destination (fintable's Force Re-Sync / resync_spreadsheets): when a spreadsheet destination is connected this APPENDS a resync op against it and returns the op id; otherwise it renders the CSV bundle ledgerExport ships and returns its receipt -- the row counts and the four URLs the same books are downloaded from (transactions.csv, accounts.csv, and the QuickBooks pair: quickbooks-bank.csv for QuickBooks Online's Upload from file, quickbooks.iif for QuickBooks Desktop, which is the one that carries the categories), plus destinations: known when the destination list was read and found empty, unreadable when it could not be read at all -- the CSV bundle is real either way, but only known means nothing was connected. Booked lines only; pending is never exported

Parameters: destination_id — which connected destination to re-sync; the newest live one by default

POST /api/v1/sync-now

MCP tool: sync_now

ask for a bank sync now: 202 with state recorded|already-pending (one request per tenant at a time), 409 when nothing is connected; it is a REQUEST the watcher consumes -- list_sync_history is what says it ran

Parameters: —

POST /api/v1/auditor/grants

MCP tool: offer_auditor_grant

offer these books to a named person: files a grant request and TEXTS A SIX-DIGIT CODE TO THE CUSTOMER'S OWN ENROLLED PHONE (never to the viewer), returning only request_id -- the code is confirmed back at POST /api/v1/auditor/grants/{request_id}/confirm, which is what actually mints the grant. SESSION ONLY: a grant is offered by the customer, signed in, so a bearer token (and therefore this MCP tool) is refused 403 -- an auditor that could mint its own grant would renew itself forever. 503 when this deployment holds no outbound SMS credential, checked BEFORE the row is filed

Parameters: viewer_name required — who is being let in, as the customer's own audit list will show them forever
viewer_channel required — the viewer's phone in E.164 (+15551234567) -- it is half the code digest's salt, so a number spelled one way here and another at confirm makes a RIGHT code fail as a wrong one

POST /api/v1/auditor/grants/{request_id}/confirm

MCP tool: confirm_auditor_grant

spend the texted code and mint the grant: 200 carries grant (the live row, with grant_id) and token, an auditor-scope credential good for 034's 24 hours and no longer, which reads GET /api/v1/auditor/view and nothing else. 404 names a request id that does not exist; a 403 names the vault's own reason (wrong code, too many attempts, expired request) rather than borrowing the 404. SESSION ONLY: the code went to the customer's phone and only the customer spends it

Parameters: request_id (in path) — the id POST /api/v1/auditor/grants returned
code required — the six digits texted to the customer
viewer_name required — the same name the request was filed under

POST /api/v1/auditor/grants/{grant_id}/revoke

MCP tool: revoke_auditor_grant

take a grant back: the row is stamped revoked_at and every later read through it is refused BY NAME forever (a revoked grant is never resurrected -- a new look needs a new grant). It is a POST and not a DELETE on purpose: this server has do_GET and do_POST and nothing else, so a DELETE would have put the customer's most safety-critical control on the only path with none of _api_write's seven walls behind it. SESSION ONLY: only the customer takes a grant back

Parameters: grant_id (in path) — the grant to revoke, as GET /api/v1/auditor/grants lists it

GET /api/v1/auditor/view

MCP tool: get_auditor_view

the accountant's own read of the books under a live grant: months (calendar-month totals off the one relation the dashboard reads), pocket_flagged (the owner add-back list) and receipts (the provider's verbatim record), composed under one identity and STAMPING its own use so the customer's list can show when the grant was last read. THE GRANT ID COMES OFF THE TOKEN'S LABEL, never off a query string -- a token that could name its own grant could name somebody else's -- so this takes no params and answers 403 to any credential that is not an auditor grant, including the bearer PAT the MCP face carries. An EXPIRED or REVOKED grant is a 403 naming why and never a 404: 'no such grant' and 'your grant died' are different sentences to the person holding the link

Parameters: —

In build now

Named rather than drawn, so the list above is only what answers today:

  • public data API
  • OAuth app registry
  • disconnect a bank -- one-way at the provider, so it stays the Accounts room's two-click form and is not an API verb