Design rules
These rules hold across the whole surface — each operation’s page restates only what is specific to it.Idempotency: client-supplied actionId
Every invoice and reserve write takes a client-minted actionId (a uuid)
that becomes the recorded action’s journal id. Retrying the identical
request replays the original outcome — server-minted values (invoice id and Transaction ID, the created
marks’ payment ids) included — instead of applying twice; duplicate
resolution runs before every other check, so a replay succeeds even with a
stale If-Match. Reusing an id with a different action or payload is
409 ACTION_ID_REUSED. A payment gesture becomes one action per per-line
mark: the actionId anchors the FIRST mark’s action, sibling marks mint their
own ids in the same transaction, and a replay reconstructs the full set.
Compound endpoints follow the same rule per client-authored action: the
event import takes one
actionId per create and per reserve set, commits atomically, and
short-circuits a retry when your ids are already journaled. The
payee merge takes no actionIds at
all — it is idempotent by convergence. The
policy invoice batch also
takes no actionId: its per-invoice watermarks protect the input snapshot and
the whole policy scope is serialized in one database transaction. Bill review,
configuration import, approval reset, the read-state rebuild and Delete All
Financial Data take none either.
Authorship: the optional author label
Every write except the policy-invoice batch accepts an optional author
(1–255 characters) — a display label for the source system’s author, e.g. the
person acting in your system. The batch body is exactly its detached explicit
plan and has no author field. Where the app shows who recorded a change, a
labeled row reads as your label; an unlabeled row reads as the built-in
“External API” actor. Rows written through this API are always visibly marked
as API-written regardless of the label — author is display attribution,
never an identity: it cannot impersonate an in-app user, and the recorded
acting principal is always the API surface itself. On an idempotent replay the
original record — original label included — is returned and the retried
author is ignored.
Two invoice identifiers: transactionId and invoiceNumber
Every invoice row carries two identifiers. transactionId is the invoice’s
Transaction ID, INV-XXXXXX-XXX: the server generates it when the invoice
is created, it is unique within the company, and it never changes or gets
reused. Use it, or the invoice id, to name an invoice. invoiceNumber is
the Invoice Number printed on the invoice by its sender, or null. It is
optional free text with no uniqueness: trimmed, a blank value means none, and
more than 255 characters returns 400. Create Invoice, Import Event Financials
and every invoice plan (Update Policy
Invoices, a policy transaction’s or bind by value’s invoicePlan, and a plan
saved on a quote) accept it, and Update Invoice may change it before and after posting. An invoice a
plan creates defaults to its own Transaction ID there when the plan states no
invoiceNumber; a stated one, such as another system’s number for an imported
policy invoice, is kept instead. Error bodies
that name invoices, such as financialBlockers.sampleTransactionIds on a
blocked delete, name them by Transaction ID.
Concurrency: the If-Match watermark
Every invoice row carries headJournalId — the id of the last action that
touched it. Every single-invoice write after creation must send it as
If-Match. A stale value is 409 IF_MATCH_CONFLICT with the invoice’s
current row in the body (error.currentInvoice): re-read, reconcile,
retry with its headJournalId. A missing or malformed header is 400.
Reserve writes and the compositions are exempt — they serialize on scope
locks instead.
Stable error codes (422)
A write whose payload is well-formed but rejected by the books fails with
422 and a stable machine-readable code (LIVE_PAYMENTS,
AMOUNT_EXCEEDS_BALANCE_DUE, INVALID_AMOUNT, INVOICE_VOIDED,
LINE_ITEMS_IMMUTABLE, UNKNOWN_ID, …— every
write’s 422 schema lists them, and a draft edit or re-link can also return DRAFT_ANNEX_CONFLICT).
The policy invoice batch’s refusals use their own finv2-… codes, plus
LIVE_PAYMENTS for a void of an invoice with payments
(Invoice plans). Codes are
contract; messages may be reworded.
Preconditions fail closed: a rejected write leaves no journal record, no
ledger change, no read-state change.
Malformed payloads (unknown fields, both links.event and links.policy,
both or neither payment mode) are schema-level 400s. Unknown path
resources (invoice, event) are 404; the policy invoice batch reads an
unknown policy as one that states no billing
(422 finv2-policy-invoice-missing-aggregate). A valid API key naming another
company in the URL is 403.
Writes return refreshed read state
Every write’s response is the refreshed read state, not an acknowledgement: invoice writes return{journalIds, invoice, payments} (plus top-level
paymentIds and createdPayments — the created mark rows — when recording
a payment); reserve writes return the scope’s new expectedTotalCents.
journalIds carries every journal id the write emitted — a payment’s sibling
marks and companions included, e.g. the automatic reserve unwind composed
when a pre-horizon eroding payment is removed. The policy invoice batch
returns {journalIds, invoices, scheduledDates}: the policy’s whole invoice
set after the batch. There is one invoice representation: reads and write
responses share the same invoice shape, except that the batch’s invoice rows
also carry approvedBy.
Amounts and dates
All amounts are integer cents. On line items, direction never rides the sign:amountCents is the magnitude a user would type, and a negative
amount is a credit within the item’s own frame — it posts opposite the line
item type’s expected direction (e.g. a reversal or refund); the posting rule
always comes from the type, never the sign.
The invoice’s document scalars are oriented net-cash figures:
totalAmountCents sums the line amounts oriented by their types’ directions
(payable +, receivable −) — the net cash the document commits to move;
amountPaidCents is the oriented sum of live payment marks — net cash moved
so far; balanceDueCents is their difference — net cash remaining, positive
= out, negative = in. A document whose oriented due is negative is an open
credit (money in), and the due may move non-monotonically as
opposite-direction lines settle.
A payment is a per-line settlement mark, signed in its line’s frame, and
settles that line’s remaining toward zero — per line, never as a
document scalar; an invoice is paid when every line is settled, never when
a scalar hits zero. The record-payment request names its marks in that same
line frame, or asks for the whole balance due (see
Record Payment). A reserve
scope’s remaining reserve is likewise a signed balance — the identity
Reserves + Paid = Expected Total is its only law, and the remaining may read
negative (over-paid against a standing estimate).
Balance reads return raw
signed cents in the ledger convention with each account’s role and
provenance direction (and each category’s expectedDirection) embedded;
the carrying side derives from role × direction (assets/expenses-side
accounts carry debit, liabilities/income-side carry credit), so clients
orient displays themselves. Dates are ISO YYYY-MM-DD; timestamps are ISO
8601.
Cursor pagination
List reads are cursor-paginated: pass the previous page’snextCursor back
as cursor until it is null. Cursors are opaque — echo them verbatim.
API Endpoints
Invoice reads
- List Invoices (
GET /financials/invoices) — global and entity-scoped lists in one endpoint: filter by status, category, linked event/policy/payee, Invoice Number (number), Transaction ID (transactionId), and date ranges; cursor-paginated. Deleted invoices are hidden. - Get Invoice (
GET /financials/invoices/{invoiceId}) — the whole current document with its live payments andheadJournalId. Returns deleted invoices too (a delete’s confirmable terminal state).
Invoice writes
- Create Invoice (
POST /financials/invoices) — the server mints the id and Transaction ID, and the request may carry the sender’sinvoiceNumber; links (an event, plus payee) are optional and can attach later. - Update Invoice (
PUT /financials/invoices/{invoiceId}) — once an invoice is POSTED its line items and category are immutable (echo the stored set verbatim; any deviation is422 LINE_ITEMS_IMMUTABLE, at any payment count): the mutable remainder is dates, memo, andfieldData, and financial-definition corrections are void-and-recreate. TheinvoiceNumberremains editable after posting. Drafts still replace their document wholesale until finalize, with one exception:invoiceNumber. Leaving it out of the body keeps it on any invoice, draft or posted, andnull(or blank) clears it. Links excluded either way (they change via the endpoints below). - Finalize Invoice (
POST …/finalize) — post a draft: its charges reach the ledger and anydraftPaymentsbecome real payments. - Delete Invoice (
DELETE /financials/invoices/{invoiceId}) — terminal; live payments are swept in the same action. - Re-link Invoice (
POST …/relink) — attach/detach/move the event link; zero ledger rows; requires no live payments. - Change Invoice Payee (
POST …/payee) — set/clear/change the payee; requires no live payments. - Record Payment (
POST …/payments) — eitherpayBalanceDue: true(settle every open line, both directions) or explicit per-lineallocations, exactly one per request; returns the created mark rows;erodeReservessteers reserve erosion on reserved-category invoices. - Remove Payment (
DELETE …/payments/{paymentId}) — exact negation; the horizon rule’s companion reserve unwind composes automatically. - Void Invoice (
POST …/void) / Restore Invoice (POST …/restore) — write the charges down to zero and back; both have defined zero-row no-ops (re-void, restore of a non-voided invoice). A voided invoice keeps its originaltotalAmountCentsandbalanceDueCentson the read, so leave voided invoices out when adding up what is owed. - Approve Invoice (
POST …/approve) / Unapprove Invoice (POST …/unapprove) — flip the document’s approval state; zero ledger rows. Payments recorded on a live invoice that requires approval (approvalRequired: true) need it, so approve before recording one — or let Record Payment approve for you (below). Parsed payment marks carried by a draft are the exception: Finalize Invoice materializes them without changing the draft’s approval state. Both approval actions needinvoice.approve, which is also the whole authority: the company’s in-app approval rules never apply to API callers. Re-approving keeps the ORIGINAL approval; unapproving a paid or partially-paid invoice is422 UNAPPROVE_PAID.
Policy invoices. A policy-linked invoice allows an Invoice Number
correction with all other document fields unchanged. Other document changes
require the policy invoice batch.
Create (with
links.policy), Update beyond the number, Finalize,
Void, Restore, Delete and Re-link (from a policy or onto one) refuse it with
422 POLICY_INVOICE_BATCH_ONLY, whose message names that policy’s batch
and invoice read paths. Record Payment, Remove Payment, Approve,
Unapprove, Change Invoice Payee (while it has no payments), bill review and
Merge Payee still work on it. A voided policy invoice cannot be restored:
the batch creates its replacement.Which invoices require approval. Every invoice carries
approvalRequired, recorded when it is created: true for money going out
(a positive totalAmountCents) on an invoice type whose “Money-out invoices
require approval” setting is on, false for money coming in and for types
whose setting is off. A zero total follows its type’s expected direction.
Each type’s setting is moneyOutRequiresApproval on List Transaction
Categories, List Policy Invoice Types and the configuration export. Changing
it through the API or a configuration import reaches new invoices only.
Changing it in the app’s Financials settings offers to apply the change to
the type’s open money-out invoices (draft, owed or partially paid); each one
it reaches then reads the new approvalRequired. Paid, voided and deleted
invoices never change. An invoice with approvalRequired: false takes
payments without an approval, and nothing approves it; an approval it
carries is kept but no longer matters.Approval and payments. Recording a payment on an UNAPPROVED invoice that
requires approval approves it automatically when the calling key holds
invoice.approve (in the same transaction, before the payment
marks); a key without the permission gets 422 NOT_APPROVED instead. The
auto-approval shows up as approved: true on the response’s invoice row and
is deliberately absent from journalIds, so an idempotent actionId replay
returns a byte-identical body.Draft payment annexes. Finalizing a draft applies its
draftPayments as
real payment marks but does not approve the invoice. An unapproved draft can
therefore finalize as partially_paid or paid while still returning
approved: false and approvedAt: null.Bill review
A bill review checks one invoice against the company’s own bill review rules and reports what it thinks is wrong with it. It is asynchronous: starting one queues background work and returns immediately, and the verdict arrives on the read.- Start Invoice Bill Review (
POST …/bill-review) —202with therunIdto poll. No request body, noactionId, noIf-Match: the rules are the company’s own (snapshotted when the run is created) and the invoice version is pinned from its current head, so there is nothing to send, and a review records a verdict beside the invoice rather than journaling an action against it. Re-posting while a review of this invoice is under way creates nothing and returnsoutcome: in_flightwith the run already running; once that run is terminal, the next call mints a fresh one. A company at its background-work capacity gets a429and nothing is queued. - Get Invoice Bill Review (
GET …/bill-review) — the latest run, its verdict and its findings. Poll untilstatusissucceededorfailed;passedisnulluntil then, and staysnullforever on afailedrun, so readerrorthere rather than reading an emptyfindingslist as a pass.billReviewisnullwhen the invoice has never been reviewed.
Findings can be stale, and you check that yourself.
invoiceHeadJournalId is the invoice version the run judged, pinned when the
review was started; a review is never re-run because the document changed. If
it differs from the invoice’s current headJournalId, the findings describe an
earlier version — start a fresh review. Dismissals (dismissedAt) are stamps
an operator applies in the app, not deletes, so dismissed findings are still
returned; filter on the field if you only want outstanding ones.Reserves
- Set Reserves (
PUT /financials/events/{eventId}/reserves/{categoryId}) — absolute “set expected total to X” per(event, reserved category)scope; previous total and delta are recorded server-side. X is unconstrained — a set below the scope’s paid-to-date is legal and leaves the remaining reserve negative. - Reset Reserve History (
POST …/reserves/{categoryId}/history-reset) — zero the scope’s remaining expectation and mark the reserve-update feed’s horizon; idempotent-shaped, legal on deprecated categories.
Compositions
- Import Event Financials (
POST /financials/events/{eventId}/import) — wholesale refresh in one transaction: sweep the event’s current invoices, create the new slate, set expected totals. Importing replaces, it does not append. - Merge Payee (
POST /financials/payees/{payeeId}/merge) — repoint every linked invoice at another payee, removing and re-recording payments so attribution follows; idempotent by convergence. - Update Policy Invoices
(
POST /financials/policies/{policyId}/invoices/batch) — the after-the-bind door for a policy’s invoices: one atomic plan that voids and creates any of them. A plan that voids every active invoice, or gives a policy with none its first, moves the policy between the billing and invoicing layers and names the move inpricingStackLayerChange(The invoice batch). See Invoicing a policy and Invoice plans.
Balances and feeds
- Get Balances (
GET /financials/balances) — company-wide per-category rollups with per-account detail, raw signed cents. - Get Entity Balances (
GET /financials/entities/{entityType}/{entityId}/balances) — one entity’s slice in the same shape (event,policy, orpayee); also the read for a scope’s current expected total before setting reserves. - List Reserve Updates (
GET /financials/events/{eventId}/reserve-updates) — an event’s reserve-update feed: user updates, automatic eroding-payment rows, and history-reset markers. - List Company Reserve Updates (
GET /financials/reserve-updates) — every event’s reserve-update feed in one cursor-paginated read, each row with its event and running reserve: the rows of the app’s company-wide Reserve Updates export, for copying reserve history into a data warehouse.
Configuration discovery
- Export Financials Configuration (
POST /financials/config/export) — returns the complete, deterministically ordered Financials Config Document v2 using natural keys only. Policy line items are flat underpolicyFinancials.lineItemTypes, each withallowedOn: [{ invoiceType, label? }]. Omitted labels are unconfigured; null explicitly selects the canonical item name. Existing duplicate item names retain separate identities. Import refuses ambiguous identity matches and never merges existing items. Version 1 is no longer accepted. - Import Financials Configuration (
POST /financials/config/import) — additively materializes missing vocabulary and reporting frames. Existing natural keys are never mutated, except that an invoice type takes amoneyOutRequiresApprovalthe document states (for new invoices only);?dryRun=truevalidates and reports the exact plan without financials-config writes.?expectedContentHash=with the export’sX-Content-Hashheader refuses the import with409if the configuration changed since that export. - List Transaction Categories (
GET /financials/config/categories) — read-only discovery of the category and line item type ids write payloads cite (includeDeprecated=trueto resolve old references). Categories and line item types are configured in the app, or added with Import Financials Configuration. - Set Transaction Category Money-Out Approval
(
PUT /financials/config/categories/{categoryId}/money-out-approval) — sets whether a category’s money-out invoices require approval. The change reaches new invoices only.
Policy financials configuration
All policy vocabulary routes requirefinancials.configure, including the two
lists. List Policy Invoice Types
and List Policy Line Item Types
return IDs, deprecation marks and usage metadata; includeDeprecated=true includes
all identities. Policy line items are independent company definitions and may be
allowed on several invoice types, with a different display label on each.
Create with POST /financials/config/policy-invoice-types or
POST /financials/config/policy-line-item-types. Rename with PATCH /{id}, delete
with DELETE /{id}, and deprecate/reactivate with POST /{id}/deprecate or
POST /{id}/reactivate under the matching collection. Writes return the full
refreshed Financials settings, including deprecated rows. The invoice-type create
requires kind: "operating", expectedDirection, and lineItemTypes (which may be
empty). Independent line-item creation takes only name and direction.
Use PUT /policy-invoice-types/{id}/expected-direction, /payee-defaults,
/presets, or /money-out-approval beneath /financials/config to replace those
settings. A money-out-approval change reaches new invoices only. Set associations
with POST /financials/config/policy-line-item-types/associations. These operations
follow the same rules as the Financials settings page.
A non-deleted invoice citation blocks rename and delete (vocabularyLock: "invoice");
deprecation keeps existing citations intact. Historical postings, deleted invoices,
and policy pricing labels alone do not lock billing vocabulary. An original-parent
invoice type with hasLegacyLineItems: true requires explicitly deleting its unused
legacy line items first. Read both lists before a multi-step replacement and check
each result: separate HTTP deletes are separate transactions. Import remains additive
and never removes existing vocabulary.
Validation
- Check Financials Read-State Consistency (
GET /financials/validation/check) — read-only: verify a scoped window of the derived read state against a replay of the journal; returns the mismatch list. - Rebuild Financials Read-State (
POST /financials/validation/sync) — rebuild the derived read state from the journal under an exclusive company-level lock; returns per-table rebuilt counts. Idempotent, safe at any time.
Operational reset (company.reset)
- Delete All Financial Data (
POST /financials/deleteAll) — hard-delete every financial record the company holds (invoices, payments, journal, ledger, balances) while keeping financial configuration (categories, line item types, approval config). The first rung of the breaking-config reset ladder: the per-type entity wipe (POST .../entities/{entityType}/deleteAll) rejects with409while financials hold a live claim, and this endpoint is its unblock path. Requirescompany.reset, and only on a Sandbox Company: both bulk wipes require Sandbox mode for initialized companies. Live and Retired companies return409 CompanyModeConflict. Companies awaiting lifecycle initialization retain the legacyDeleteGuardConflictpolicy.
Attachments ride the documents API
Invoice endpoints take no files. Upload documents through the platform Company Files API and they attach alongside the invoice — an invoice can arrive unlinked and gain its event link later for the price of one action.Deliberate exclusions
The API deliberately does not offer a chart-of-accounts endpoint, time-travel query parameters, raw journal/ledger dumps, or a per-invoice action history. The configuration document import is additive portability, not CRUD. The journal is the system’s internal source of truth; the read surface above is the contract. Bill review follows the same rule: the rules themselves are managed in the app, so there is no endpoint to write them, no endpoint to dismiss a finding, and no history of earlier runs — only the latest one per invoice.Permissions
Invoice writes are permitted by the invoice’s stage:draft; finalized
(status owed or no_charges); paid (status partially_paid or paid); and
void (status voided). Each stage has its own create, edit and delete
permission, written invoice.create-<stage>, invoice.edit-<stage> and
invoice.delete-<stage> below. Voiding has invoice.void-finalized and
invoice.void-paid.
A write that could be done as several separate requests is allowed in one
request when the key holds the permissions for those requests. For example,
deleting a paid invoice is allowed with invoice.delete-paid, or with
payment.delete and invoice.delete-finalized (remove its payments, then
delete it). A refused write returns 403 naming the permissions that would
allow it.
An approved invoice that requires approval (approved and approvalRequired
both true) is changed only under the authority to approve it: Update
Invoice, Re-link Invoice, Change Invoice Payee, Void Invoice, Restore Invoice,
Delete Invoice, and a policy invoice batch that voids it, also require
invoice.approve. Recording and removing payments do not.
