Guides
Ledger Semantics & Lifecycles
What each write actually does to the books: which state every operation is valid in, which fields are accepted and then ignored, and which calls cannot be undone. 33 documented behaviours across 12 resources.
The same rules your agent already has
These sections are ported from the topics the COUNT MCP server serves to agents throughCOUNT_knowledge, so an agent looking up a rule and a developer reading this page get the same answer. Each section shows the COUNT_knowledge topic id it corresponds to.Accounting APIs fail differently from most REST APIs. A call can return 200 and still not have done what you asked, and a call that succeeds can quietly detach history you expected it to protect. Everything below is behaviour we have observed and confirmed against the live API — including the parts that are surprising. Read the resource you are about to write to before you write to it, and follow Accounting Playbooks for the ordered sequences these rules sit inside.
How to read the severities
Irreversible
Destroys or silently rewrites data, and cannot be undone through the API.
Silent
The call succeeds without doing what you asked — a no-op, or a field accepted then ignored.
Constraint
A constraint that fails loudly, almost always with a 400 you can read and act on.
Irreversible and silent behaviours
Every Irreversible rule on this page, in one place. If you are reviewing what an agent is allowed to do in a production workspace, start here.
| Resource | Behaviour |
|---|---|
| Recurring invoice templates | Omitting nextInvoiceDate on any update nulls it out |
| Bank transactions | An empty splits array un-splits the transaction |
| Manual journal entries | lines replaces every existing line |
| Vendors | create_vendor can reactivate and overwrite an inactive vendor |
| Vendors | A successful delete detaches historical rows |
| Tags and tag groups | delete_tag detaches the tag from every transaction, without warning |
| Tasks | delete_task destroys attachments, tags, and recurring schedules |
| Payroll pay periods | The batch is not atomic |
Grant write access to a test workspace first
None of these behaviours are gated behind a confirmation step at the API layer. If you are wiring an agent to a client’s live books, put your own approval step in front of the writes you care about — see OAuth consent experience for what the user authorizes.Invoices
Invoices move draft → approved → sent → paid. Approving posts journals, so most write operations behave differently on either side of that step. There is no revert-to-draft route.
| Operation | Valid in | Constraint |
|---|---|---|
create_invoice | Creates in draft | dueDate is effectively required for invoiceType "invoice" and "estimate" — 400 without it, even though the schema marks it optional. Only credit memos are exempt. |
approve_invoice | draft | Posts journals. Re-read the invoice afterwards instead of trusting the 200 — see the verification rule below. |
send_invoice | approved | 400 on a draft. Body fields are to, subject, message, and sendCopy. |
update_invoice | Any state | Send only the fields you are changing; internal lifecycle fields are stripped and ignored. |
delete_invoice | Any state, including approved | Blocked only by a nonzero paid or refunded amount, or existing payment links — not by being past draft. |
assign_transaction_to_bills_invoices | approved, sent, unpaid, partial | Not valid on a draft. Accepts an Income transaction for a normal payment, or an Expense transaction for a refund. |
unassign_invoice_transaction | partial, paid | Unwinds an applied payment. |
Verify that approve_invoice actually appliedSilent
approve_invoice is intended to flip draft → approved, but an empty-body call — the documented standard usage — may not persist approved/isDraft. Always re-read the invoice with get_invoice and check those fields rather than relying on the 200 response alone.
There is no revert-to-draft endpointConstraint
To correct an approved invoice, apply a credit memo. Structural changes are only free before approval, so recreate at draft stage when you still can.
send_invoice ignores recipients, cc, bcc, and attachPdfSilent
Those field names look plausible and are accepted, but do nothing. The real fields are to (defaults to the customer email), subject, message, and sendCopy.
delete_invoice is not draft-onlyConstraint
An approved-but-unpaid invoice deletes cleanly. Do not write logic that expects a draft-only 400 — check the paid and refunded amounts instead.
Bills
Bills move draft → approved → paid. Approval is gated by a real approver workflow, not just by document state, and payment accepts Expense transactions only.
| Operation | Valid in | Constraint |
|---|---|---|
approve_bill | draft with line items | Also gated by the approver workflow: a caller who is not an assigned approver and not owner/admin gets 403 or 400 depending on approvalStatus, and the bill total can be rejected against the caller’s configured approval dollar limit. |
update_bill / delete_bill | draft | Restricted after approval or payment. delete_bill returns 400 when paidAmount > 0, and also blocks when the bill is linked to a registered fixed asset. |
assign_transaction_to_bills_invoices | approved | Expense transactions only — an Income transaction against a bill always 400s. Pass matchingType "bill". |
unassign_bill_transaction | partial, paid | withCaution: true is required to remove a reconciled payment. It is not optional in that case. |
apply_vendor_memos_to_bill | approved | The only way to apply a vendor credit memo. Vendor memos never route through assign_transaction_to_bills_invoices. |
Approval can fail for permission reasons, not data reasonsConstraint
A well-formed approve_bill call still fails when the caller is not one of the bill’s assigned approvers, or when the total exceeds their approval limit. Treat 403 here as a workflow outcome to surface to the user, not a bug to retry.
Unwind payments before deletingConstraint
delete_bill returns 400 while paidAmount is above zero. Unassign the applied transactions first.
Credit memos
A credit memo is an invoice with invoiceType "memo" and follows the same draft → approved lifecycle. Applying one is how you correct an approved invoice, and every constraint below returns 400 rather than partially applying.
| Operation | Valid in | Constraint |
|---|---|---|
create_invoice | Creates in draft | Pass invoiceType "memo" and the same customerUuid as the invoice you intend to correct. dueDate is not required for memos. |
apply_multiple_credits_to_single_invoice | approved memo, approved invoice | Many credits onto one invoice. |
apply_single_credit_to_multiple_invoices | approved memo, approved invoices | One credit spread across several invoices. |
remove_invoice_credit | applied | The only way to unwind an application. The apply tools do not work in reverse. |
The memo and the invoice must share a customerConstraint
Cross-customer application returns 400 every time. A memo raised against the wrong customer can never be applied to the target invoice — check customerUuid at creation, not at application.
Neither document may be a draft, and a spent memo cannot be reusedConstraint
Both the memo and the invoice must be approved. A memo that is already fully applied or paid cannot be reapplied.
Over-applying rolls back the whole requestConstraint
The applied amount cannot exceed the invoice open balance or the memo remaining balance. If it does, nothing in the request is applied — not even the rows that would have fit.
Recurring invoice templates
New templates are created paused, and the tool that sounds like it un-pauses them does not. This is the single most common reason a recurring schedule silently never fires.
| Operation | Valid in | Constraint |
|---|---|---|
create_recurring_invoice_template | Creates with isDraft: true | The template will not generate invoices in this state. |
update_recurring_invoice_template | Any state | The only way to activate a template: send isDraft: false explicitly, together with the current nextInvoiceDate. |
resume_recurring_invoice_template | Any state | Requires nextInvoiceDate (400 without it) but only ever sets that field — it never clears isDraft. |
list_recurring_invoice_templates | isDraft: false only | Always filters to active templates. A paused template is absent from this list by design, not because of a bug. |
get_recurring_invoice_template | Any state | No isDraft filter — use this to check a template’s real state when list does not show it. |
Resuming a template does not activate itSilent
resume_recurring_invoice_template and pause_recurring_invoice_template never touch isDraft. A template left at the create-time default stays invisible to list and is skipped by the generation cron, which also requires isDraft: false. Call update_recurring_invoice_template with isDraft: false to actually activate it.
Omitting nextInvoiceDate on any update nulls it outIrreversible
Every update to a template must resend the current nextInvoiceDate, even when that is not the field you meant to change. Leaving it out silently clears the value and pauses the schedule.
Bank transactions
Categorizing, splitting, and deleting a transaction all depend on what it is already linked to. One call in this group destroys data when a field is omitted rather than treating the omission as a no-op.
| Operation | Valid in | Constraint |
|---|---|---|
change_transaction_category | Unlinked transactions | Blocked while the transaction is linked to a bill, an invoice, another linked transaction, or a deposit. Unassign first. |
split_transaction | Unlinked transactions | Line categories are subject to the same blocked-category list. Never send an empty splits array — see the rule below. |
delete_transaction | Unlinked, unreviewed, unreconciled, unsplit, non-transfer | No override parameter exists on this tool. Soft delete. |
create_transaction | Income, Expense | type "Transfer" is not fully supported through the UUID-based tool today — expect a 400 rather than a working transfer. |
An empty splits array un-splits the transactionIrreversible
Omitting splits, or sending [], does not no-op. It un-splits an already-split transaction, permanently destroying the split-child rows and restoring the parent to its original amount. Only send an empty array when that is genuinely the intent.
System control accounts can never be a transaction categoryConstraint
Accounts Payable, Accounts Receivable, Retained Earnings, and Accumulated Depreciation are rejected with a 400 on create_transaction, change_transaction_category, and split line categories. Post to those accounts through the documents they belong to instead.
Payment direction is asymmetric between invoices and billsConstraint
assign_transaction_to_bills_invoices accepts an Income or an Expense transaction for an invoice (payment or refund), but only an Expense transaction for a bill. An Income transaction against a bill always 400s.
Manual journal entries
Manual entries are deliberately permissive on create and deliberately strict on edit. Anything COUNT generated itself is read-only.
| Operation | Valid in | Constraint |
|---|---|---|
create_journal_entry | Always | Debits are not required to equal credits. This is intentional — see the rule below. |
update_journal_entry | Manual and Square-integration entries | Always resend the full lines array. Rejected for system-generated entries and for any entry already linked to a transaction, bill, or invoice. |
delete_journal_entry | Manual and Square-integration entries | A true row destroy, not a soft delete. Rejected for system-generated and linked entries. |
Unbalanced entries are allowed on purposeConstraint
create_journal_entry and bulk_create_journal_entries accept postings where debits do not equal credits, to support period-close accruals, payroll clearing, and imports. Do not assume the API will catch an out-of-balance entry for you — validate before posting if your product needs balance enforced.
lines replaces every existing lineIrreversible
When you send lines on an update, the array replaces all existing lines wholesale. Fetch the entry first if you only mean to change one of them. Omitting lines is not a safe alternative — it can trigger a server error rather than leaving the existing lines untouched.
System-generated entries are read-onlyConstraint
Entries produced by invoices, bills, and payroll return 400 on update and delete. Only manually-created and Square-integration entries are editable.
Chart of accounts
Some account properties can only be set at creation time, and sub-type ids must be looked up rather than guessed. Finish the chart of accounts before importing anything that posts to it.
| Operation | Valid in | Constraint |
|---|---|---|
list_account_sub_types | Before any create | Filter by type — Assets for bank, Liabilities for credit card, Income, or Expenses — and copy the integer id of the matching row. |
create_account | Always | Supports parentAccountUuid for nesting and taxes (tax UUIDs) at creation time. Both name and subTypeId are required. |
update_account | Always | taxes is supported and replaces the account’s full set of taxes. Numeric ids are rejected. Re-parenting is not supported — see below. |
delete_account | Accounts with no postings | A soft delete: the row and its name stay reserved. Fails with HAS_JOURNAL_ENTRIES once the account has been posted to — set status inactive with update_account instead. |
Re-parenting only works at create timeSilent
create_account resolves a parentAccountUuid, but update_account has no UUID-to-id resolution on the update path, so any parent field you send there is silently ignored. Create the account in the right place, or recreate it.
Never guess or probe subTypeIdConstraint
Sub-type ids are sparse global integers, not a predictable sequence. Always read them from list_account_sub_types, or reuse subType.id from an existing account of the same type.
Finish the chart of accounts before importingConstraint
An account that has journal entries can no longer be deleted, only deactivated. Build the full chart first, then import bills and transactions against it.
Delete semantics differ across resourcesConstraint
delete_account and delete_transaction are soft deletes. delete_journal_entry destroys the row for manual entries. Do not generalise one resource’s delete behaviour to another.
Vendors
create_vendor is a find-or-create, not a strict insert, and a successful delete detaches history rather than blocking on it. Both are easy to mistake for something safer than they are.
| Operation | Valid in | Constraint |
|---|---|---|
create_vendor | Always | Matches on normalized name, website, and email. An active match returns 400; an inactive match is reactivated and overwritten — see below. |
update_vendor / delete_vendor | Vendors not linked to a contractor | Both return 400 ("Can not edit/delete a person contractor") when the vendor is linked to a contractor person record. Edit the contractor instead. |
delete_vendor | No open bills or memos | 400 while the vendor has any approved, unpaid bill or vendor memo. |
create_vendor can reactivate and overwrite an inactive vendorIrreversible
When the normalized name, website, or email matches an inactive vendor, that vendor is silently reactivated and its fields are overwritten with your payload. You get back an existing, mutated vendor rather than a new one. Call list_vendors first if you need to know whether a name is already in use.
A successful delete detaches historical rowsIrreversible
Once the open-document check passes, deleting a vendor does not block on history: transactions and journal entries that referenced it silently lose their vendor reference. Deactivate instead of deleting when the history matters.
Tags and tag groups
Tags accept a field they never store, create is a find-or-create, and delete is the one hard delete in this group with no in-use check.
| Operation | Valid in | Constraint |
|---|---|---|
create_tag | Always | Find-or-create by name: an exact existing name returns the existing tag, still with HTTP 201, rather than erroring or duplicating. |
update_tag | Always | Only name is persisted. |
delete_tag | Always | A hard delete with no in-use check. |
create_tag_group / update_tag_group | Always | A tag belongs to one group at a time, and that membership is enforced — 400 on conflict. |
Tag color is accepted and never storedSilent
create_tag and update_tag both accept a color field in the schema, but the underlying service only writes name. Do not rely on tag color coming back from the API.
delete_tag detaches the tag from every transaction, without warningIrreversible
There is no in-use check and no confirmation step. The tag is removed from every transaction it was applied to the moment the call succeeds.
A 201 from create_tag does not mean a tag was createdConstraint
Check the returned id against what you sent if your logic depends on having created a new tag rather than found an existing one.
Expense receipts
The least-typed surface in the workspace API: create and update forward the body as raw JSON, so every constraint is enforced server-side only. Preflight these calls.
| Operation | Valid in | Constraint |
|---|---|---|
create_expense_receipt / update_expense_receipt | Always | amount is required. expenseReportTypeId and categoryAccountId are conditionally required depending on caller type, unless split rows are supplied. file is required for people-type callers. |
match_expense_receipt_manually | Unmatched Expense transactions | The target transaction must be type "Expense" — anything else returns 404 "Transaction not found" rather than a clearer validation error. Already-matched transactions return 400. |
delete_expense_receipt | Unmatched receipts | Returns 409 with error "receipt_matched" — not a generic 400 — while the receipt is matched. Unmatch it first. |
No schema validation before the request leavesConstraint
Because the body is forwarded as raw JSON, a typo surfaces as a server-side 400 rather than a client-side schema error. Call validate_payload or describe_endpoint before an unfamiliar create here.
Split receipts must match split transactions line for lineConstraint
When a receipt has split rows, the transaction it matches must also be split, with exactly matching per-line amounts.
Tasks
Task visibility is enforced server-side and differs per tool. Two tools that look interchangeable return different sets of rows.
| Operation | Valid in | Constraint |
|---|---|---|
list_tasks / get_task | firm-team and team-only visibility | Visibility is forced server-side for workspace-scoped sessions. Passing visibility=firm-only does not override it. |
list_project_tasks | All rows | Applies no visibility or soft-delete filtering, so firm-only and soft-deleted tasks can appear here even though list_tasks excludes them. |
delete_task | Always | A soft delete for the task itself, but permanently destructive for what hangs off it. |
Firm-only tasks are unreachable from workspace-scoped toolsConstraint
Firm-only rows — notably system-generated INTERNAL_TASK records — cannot be read through list_tasks or get_task no matter what visibility you pass. Firm-scoped sessions use the firm-wide task tools, which apply no visibility filter by default.
delete_task destroys attachments, tags, and recurring schedulesIrreversible
Those are removed permanently as part of the otherwise-soft delete, and do not come back if the task is later undeleted.
Two list tools, two different row setsSilent
If a task appears in list_project_tasks but not list_tasks, that is the visibility model working as designed — not a pagination or caching problem.
Payroll pay periods
update_pay_period applies employee edits one at a time and does not roll back on failure. Read the response arrays rather than treating the call as all-or-nothing.
| Operation | Valid in | Constraint |
|---|---|---|
update_pay_period | Open pay periods | Rejects any employee edit that would drive that employee’s calculated net pay negative (payroll_edit_would_make_net_negative), independently of other validation. |
The batch is not atomicIrreversible
Employees and their internal sections are applied one at a time in a fixed order. The first failure stops everything after it, but earlier successful updates are not rolled back. Read the response completed, failed, and notAttempted arrays and reconcile from there — never assume the call either fully applied or fully did not.
This tool does not advance payrollConstraint
It never confirms time entries, moves payroll to review, submits, initiates, or skips a run. Those are separate steps outside its scope.
