Every operation your AI can call
905 operations across 84 areas: 372 that read and 533 that make changes. Each one names the scope a key needs. The same list is at /api-catalog.json as JSON. Updated 2026-11-11.
Connected agents read the live version with full input schemas: GET https://app.padrino.ai/api/manifest?mine=1, or the list_endpoints and describe_endpoint tools at https://mcp.padrino.ai/mcp. How to connect, the permission switches and error behavior are on Developers.
/api/account-map-groups 5
| Operation | Scope | What it does |
|---|---|---|
GET /api/account-map-groups |
ledger:read |
List account map groups (named external charts). An account map is another chart's codes for your accounts (e.g. the accountant's QuickBooks or tax-software codes); GET /api/reports/trial-balance?map_group_id= shows the trial balance in those codes. |
POST /api/account-map-groups |
ledger:write |
Create an account map group (name unique per business, 409 if taken), then add each account's external code with POST /api/account-mappings. An account map is another chart's codes for your accounts (e.g. the accountant's QuickBooks or tax-software codes); GET /api/reports/trial-balance?map_group_id= shows the trial balance in those codes. |
DELETE /api/account-map-groups/{id} |
ledger:write |
Delete an account map group and all of its account mappings. Accounts and the ledger are untouched. |
PATCH /api/account-map-groups/{id} |
ledger:write |
Rename an account map group or change its description. |
POST /api/account-map-groups/schedule-c |
ledger:write |
Create the business's "Schedule C" account map, or add to it: every income and expense account with no mapping yet gets its suggested Schedule C line (external_code = line number, e.g. 8 Advertising, 24b Deductible meals) by subtype and name. Existing mappings are never changed. Returns added and unmatched (accounts with no confident line). { preview: true } saves nothing. GET /api/reports/year-end uses the map for its tax summary. |
/api/account-mappings 4
| Operation | Scope | What it does |
|---|---|---|
GET /api/account-mappings |
ledger:read |
List account mappings (account -> external_code/external_name), ?map_group_id= for one map. An account map is another chart's codes for your accounts (e.g. the accountant's QuickBooks or tax-software codes); GET /api/reports/trial-balance?map_group_id= shows the trial balance in those codes. |
POST /api/account-mappings |
ledger:write |
Map one account to its code in an account map group (one mapping per account per map, 409 if it exists; 404 for an unknown account or map). Several accounts may share an external code; the mapped trial balance rolls them together. |
DELETE /api/account-mappings/{id} |
ledger:write |
Remove one account mapping from its map group. The account itself is untouched. |
PATCH /api/account-mappings/{id} |
ledger:write |
Change an account mapping's external_code, external_name or notes. |
/api/accounts 11
| Operation | Scope | What it does |
|---|---|---|
GET /api/accounts |
ledger:read |
The chart of accounts: code, name, type, subtype (bank, cash, credit_card, receivable, payable, revenue, cogs... null when unknown), parent_id, is_system. ?type=expense and ?subtype=credit_card narrow it; ?code=1030 returns only that code (empty when it is free); ?search=checking (or ?q=checking) matches name or code (case-insensitive contains); an unknown parameter is a 400. |
POST /api/accounts |
ledger:write |
Add an account to the business's chart of accounts. subtype (bank, credit_card, receivable...) must belong to type; omitted, it is guessed from the name and code. An income account created while the revenue role is unassigned returns next_actions with the PUT /api/accounts/roles call that makes it the default income account for invoice lines without account_code. A contra-revenue account (subtype contra_revenue, or named for returns, refunds or allowances) created while the sales_returns role is unassigned returns next_actions with the PUT that makes it the sales returns account; creating it alone never changes where credit memos post. 409 account_code_taken if the code is already taken, with existing_account (the account holding it), suggested_code (the nearest free code after it: the rest of its ten for an off-ten code like 1043 -> 1044, else the next round code in its hundred, staying in its thousand block) and next_actions (the same create with suggested_code; a search by name in case the account already exists). |
DELETE /api/accounts/{id} |
ledger:write |
Delete an unused chart account. Accounts with journal lines or other references are refused with 409 and the reason |
GET /api/accounts/{id} |
ledger:read |
One chart account by id (code, name, type, subtype). GET /api/accounts?code= finds one by code. |
PATCH /api/accounts/{id} |
ledger:write |
Rename, renumber, retype or re-parent a chart account. A type change on an account with posted entries is refused across statements and needs confirm: true within one |
POST /api/accounts/apply-template |
ledger:write |
Add an industry template's accounts to the chart (template: service | retail | trades | restaurant). Previews by default (preview: true returns would_add and skipped, writes nothing); send preview: false after the owner agrees. Add-only: accounts whose name already exists, or whose code is held by a look-alike, are skipped with the account in the way; an account whose code is held by an unrelated account is added at the next free code (code is the new one, template_code the original, code_conflict the holder). Nothing is renamed or removed. would_add items with similar_existing look like accounts the owner already has (Rent vs Rent Expense): ask before applying and leave those out with exclude_codes (template codes). |
GET /api/accounts/export |
ledger:read |
Export chart of accounts as CSV Query params: - format: "csv" (default) - export format |
POST /api/accounts/import |
ledger:write |
Import a chart of accounts when migrating (switching) from QuickBooks, Xero or a spreadsheet: a CSV or spreadsheet export (QuickBooks, Xero or a plain list). Send preview: true first: each preview.accounts row shows the subtype it will get (bank, receivable, payable, revenue, credit_card, payroll, owner_draws, accumulated_depreciation...), read from the name, the detail type, then the Type column as written (QuickBooks Online's Owner's Pay & Personal Expenses, Drawings and Partner Distributions are owner_draws; an equity row still read as owner_equity whose exported balance is negative gets a warning that it looks like draws). Per-account warnings carry the file row in row. A row typed Bank whose name says a card (Visa, Mastercard, Amex, Discover, credit card: Xero exports cards as Bank) imports as liability credit_card with a warning; a Bank row with a 2000s code only warns. A deposits liability (Client Deposits, Retainers, Unearned Revenue) gets deferred_revenue and a warning naming the Customer Deposits account the opening puts customer credit in (a spreadsheet's Accounts Receivable, A/R, Accounts Payable, A/P, Income, Revenue, Sales); a row left with subtype null gets a warning, and on commit a PATCH /api/accounts/{id} { subtype } next_action and status would_create | exists (an existing code, or name when codes are assigned, is skipped, never overwritten; an existing code under a different name adds existing_name and a name_differs warning naming the export's balance that is not carried over; when the existing account's type or subtype differs from the row's it is a different account: code_conflict type_differs, existing_type, existing_subtype and suggested_code (a free code in the same range), and the warning says not to use the existing code). A file with no number column whose names start with the account number (QuickBooks Desktop '1191 · Savings', sub-accounts '6000 · Utilities:6010 · Electric', or '1190 Checking') keeps those numbers as the codes and the names without them, with a warning listing the split; otherwise codes are assigned by type. A QuickBooks sub-account (Full name 'Vehicles:Accumulated Depreciation') is matched only to an account of that name under the same parent, never by its short name alone; when its short name is already another account's here or in the file it is created as '<Parent> - <Name>' (preview row renamed_from, and a warning), and a parent path that is an account here becomes its parent. In the commit's opening draft an imported deposits liability's line is on the Customer Deposits account, with a check to ask the owner. An export type of cost of sales (Xero Direct Costs, QuickBooks Cost of Goods Sold) gets subtype cogs. An imported sales tax liability (subtype sales_tax) adds a PUT /api/accounts/roles next_action to make it the sales tax account (role sales_tax_payable), with the ask_owner question. Returns the created accounts with ids, plus each row's detail type and exported balance for the opening balance step. A file whose account name column cannot be found (preview or not) is 400 missing_column_mapping with the headers found; resend with columnMapping |
GET /api/accounts/roles |
ledger:read |
Which chart account plays each automatic-posting role: accounts_payable, cash, revenue, sales_tax_payable, inventory, cogs, grni, inventory_adjustment, customer_deposits, dmv_fees_payable, sales_discounts, sales_returns. Each role shows the account it resolves to, source (mapped = assigned, default = standard code/name match, null = unresolved) and candidate accounts of the right type (at most 3 candidates and 3 other_candidates, with candidates_total and other_candidates_total; include_candidates=true lists them all). role=<role> returns just that role with every candidate and the first 10 other_candidates (add include_candidates=true for all). Bill approval/payment, PO receipt, shipment and invoice mark-paid refuse with 409 account_roles_missing until their roles resolve. |
PUT /api/accounts/roles |
ledger:write |
Assign the chart account for one or more posting roles: { roles: { accounts_payable: <account id>, inventory: <account id>, ... } }. null clears a role (falls back to the standard default). The account type must match the role. Returns the full role list as GET does (candidate lists capped). |
GET /api/accounts/templates |
ledger:read |
Industry chart of accounts templates (service, retail, trades, restaurant). ?key=service lists one template with its accounts; the default lists the templates suggested for business_type with their accounts and the rest as a summary (would_add, ask_owner, no accounts); ?key=all lists every account of every template. Each template's accounts (code, name, type; in_chart true when the chart already has it by name or a look-alike at its code, with in_chart_as naming that account; code_conflict and would_add_as when its code is held by an unrelated account and it would be added at the next free code; similar_existing for look-alikes to ask the owner about), how many it would add to this chart, the questions to ask the owner first, and which are suggested for the business_type preference. A new MarathonBooks business starts on a minimal chart; apply the fitting template with POST /api/accounts/apply-template. |
/api/agent-context 6
| Operation | Scope | What it does |
|---|---|---|
GET /api/agent-context |
workforce:read |
The context management surface: every tier in one list. Rows with business_id NULL are platform-owned (the `global` tier plus the shipped department/role defaults). RLS already refuses a non-admin write to them; the explicit isAdminUser check here exists to return a 403 that says why, instead of an opaque RLS failure. |
POST /api/agent-context |
workforce:write |
Create or update (upsert) an agent context record in the context library: a markdown note at the global, department, role, business or employee tier. Platform-owned rows (global tier, or platform: true) are admin-only. Returns { record }. |
DELETE /api/agent-context/{id} |
workforce:write |
Delete an agent context record. Platform-owned records are admin-only. Returns { ok: true }. |
PATCH /api/agent-context/{id} |
workforce:write |
Edit an agent context record (title, summary, body, load_mode, position, is_active; ref is not editable). Platform-owned records are admin-only. Returns { record }. |
GET /api/agent-context/read |
workforce:read |
the on-demand half of the design. Records marked load_mode='on_demand' never enter a system prompt; the agent sees only a one-line manifest entry and calls this when the topic actually comes up. That is the whole reason context is split into records: a prompt carries pointers, not payload. `links` lists the refs this body points at, so an agent can walk one hop further without re-reading the manifest. |
GET /api/agent-context/resolve |
workforce:read |
Workforce admin only: preview the prompt an AI employee of this business (?employee=<slug>) is given, with every context tier. Not guidance for an external agent; an agent connected with an API key reads its own instructions from GET /api/context. |
/api/agents 1
| Operation | Scope | What it does |
|---|---|---|
POST /api/agents/hire |
workforce:write |
Hire a new AI agent from a conversation with the owner (role, capability profile, the owner's brief and constraints). Returns 201 { agent, hireComplete: false, nextStep }: the agent has no work until you create its first task with POST /api/pm/tasks. |
/api/ai-chat 1
| Operation | Scope | What it does |
|---|---|---|
POST /api/ai-chat/prompt-versions/{id}/publish |
assistants:write |
Publish a prompt version and make it the live system prompt of one customer chat assistant (default: the business's default assistant; pass assistant_id to target another). Other assistants on the business are not changed. Returns the version and the assistant it went live on. |
/api/ai-workforce 35
| Operation | Scope | What it does |
|---|---|---|
GET /api/ai-workforce/activity |
workforce:read |
Per-agent live status: the live activity row merged with the latest run row (history fallback). Polled by the Live Activity dashboard. |
GET /api/ai-workforce/employees |
workforce:read |
List the workforce roster for the current business |
POST /api/ai-workforce/employees |
workforce:write |
Hire (create) an employee. Rejects an existing slug with 409 so a re-typed slug can't silently clobber an existing row's registry; edits go through PUT /employees/[id]. Every hire is wired ATOMICALLY via hireEmployee(): the employee row plus the brightcube-pm platform binding, required for BOTH kinds, since without it the daemon never registers an AI agent AND a human can't be resolved as a reassign target. For AI hires it also writes the canonical context tiers (`context` is the optional individual `<slug>.md` notes tier); hireEmployee no-ops the tiers for humans. Never assemble those rows by hand, a partial hire leaves an inert employee whose tickets sit untouched. |
DELETE /api/ai-workforce/employees/{id} |
workforce:write |
remove an employee |
GET /api/ai-workforce/employees/{id} |
workforce:read |
full registry for one employee |
PUT /api/ai-workforce/employees/{id} |
workforce:write |
edit the registry row |
DELETE /api/ai-workforce/employees/{id}/context |
workforce:write |
DELETE /api/ai-workforce/employees/[id]/context?contextId=<uuid> |
POST /api/ai-workforce/employees/{id}/context |
workforce:write |
upsert a context document. Idempotent on (employee_id, title) via the repo, so re-submitting an existing title edits its content. |
DELETE /api/ai-workforce/employees/{id}/platforms |
workforce:write |
DELETE /api/ai-workforce/employees/[id]/platforms?platformId=<uuid> |
POST /api/ai-workforce/employees/{id}/platforms |
workforce:write |
upsert a platform identity. Idempotent on (employee_id, platform) via the repo, so re-submitting an existing platform edits its connection config. |
GET /api/ai-workforce/manifest |
|
The catalog of API endpoints: path, method, required scopes, read/write and a summary. ?mine=1 limits it to what this key can call; ?profile=bookkeeping to the accounting groups; ?path=<path>&method=<METHOD> describes one endpoint with its bodySchema / querySchema and a short summary (a long one's edge cases come back as details with &verbose=1; without it, details_index names what they cover). Read GET /api/context first (start_here). |
GET /api/ai-workforce/pause |
workforce:read |
effective pause mode + task focus |
PUT /api/ai-workforce/pause |
workforce:write |
{ mode?: "off"|"soft"|"hard", focus?: string[] (priorities the workforce may work) } |
GET /api/ai-workforce/pm/lists/{listId}/fields |
pm:read |
The task field catalog (role fields plus the business's custom fields) for a task list. 400 invalid_id for a malformed listId, 404 for a list this business does not have. |
GET /api/ai-workforce/pm/lists/{listId}/statuses |
pm:read |
The workflow statuses a task list resolves to (its own set, else its project's, else the business default). 400 invalid_id for a malformed listId, 404 for a list this business does not have. |
GET /api/ai-workforce/pm/me |
pm:read |
Who this request acts as: the AI employee named by the X-Workforce-Employee header, or the signed-in user. An API key without the header gets id: null and a message saying how to act as an employee. |
GET /api/ai-workforce/pm/tasks |
pm:read |
Workforce daemon wire contract (bare array of tasks with full descriptions); agents should use GET /api/pm/tasks. ?mine=1 or ?assignee=<slug>, ?since=<iso>, and ?limit (1-200) with ?offset to take one page; without limit every matching task is returned. |
POST /api/ai-workforce/pm/tasks |
pm:write |
create a task or subtask. Body: { listId, title, description?, assigneeIds?, parentTaskId?, phaseOrdinal?, dependsOnTaskId?, customFields? } |
GET /api/ai-workforce/pm/tasks/{id} |
pm:read |
one task. |
PATCH /api/ai-workforce/pm/tasks/{id} |
pm:write |
update editable fields. Body: { description?, instructions? } - instructions is the task page's dedicated "Instructions" section (the operator's specific ask for the worker); description stays the general context. At least one is required. |
POST /api/ai-workforce/pm/tasks/{id}/archive |
pm:write |
Archive or restore a task. Body { archived: boolean }: true archives (an empty body does too), false restores. Archived tasks leave boards and queues; nothing is deleted. Returns { task }. |
PUT /api/ai-workforce/pm/tasks/{id}/assignees |
pm:write |
Set who a task is assigned to. Body { employee_ids }: the full list of employee slugs after the change ([] unassigns everyone); missing ones are added, others removed. To assign the business owner use owner.employee_slug from GET /api/setup/status; other assignees are employee slugs (not uuids). Returns { assignees }. |
GET /api/ai-workforce/pm/tasks/{id}/comments |
pm:read |
the comment thread. |
POST /api/ai-workforce/pm/tasks/{id}/comments |
pm:write |
post a comment as the acting employee. Body: { body } |
GET /api/ai-workforce/pm/tasks/{id}/context |
pm:read |
A task's subtasks, attachments and docs in one call. |
GET /api/ai-workforce/pm/tasks/{id}/fields |
pm:read |
A task's fields: the field catalog for its list plus the task's current value for each. |
PUT /api/ai-workforce/pm/tasks/{id}/fields |
pm:write |
Set one field value on a task. Body { fieldId, value }, with field ids from GET /api/pm/tasks/{id}/fields. |
GET /api/ai-workforce/pm/tasks/{id}/links |
pm:read |
List the links touching this task: tasks that block it and tasks it blocks. |
POST /api/ai-workforce/pm/tasks/{id}/links |
pm:write |
Mark another task as blocking this one. Body { blockerTaskId, linkType? }: {id} is the blocked task, blockerTaskId the one it waits on. While the blocker is open this task moves to "waiting on". Returns the link. |
DELETE /api/ai-workforce/pm/tasks/{id}/links/{linkId} |
pm:write |
Remove a link between tasks. {id} is the blocked task and {linkId} comes from GET /api/pm/tasks/{id}/links; a task no longer blocked goes back to its previous status. 404 when the task or link does not exist or the link does not touch the task. |
PUT /api/ai-workforce/pm/tasks/{id}/status |
pm:write |
Change a task's status. Body { status }, a value from GET /api/pm/tasks/{id}/statuses. The assignees are notified. Returns the updated task. |
GET /api/ai-workforce/runs |
workforce:read |
AI employee run history, newest first: ?agent=<employee slug> scopes to one employee (404 for an unknown slug), ?task=<id> to one ticket, limit 1-500 (default 100). Returns { runs }. |
GET /api/ai-workforce/runs/{runId} |
workforce:read |
A single run plus its persisted step trail, for the drill-down log view. RLS-scoped db enforces business isolation; getByRunId also filters business_id. |
DELETE /api/ai-workforce/usage-pause |
|
> lift it; daemon resumes within ~30s (its pauseEnforceTick sweep), not instantly. |
GET /api/ai-workforce/usage-pause |
|
> { pause: {...} | null } |
/api/analytics 3
| Operation | Scope | What it does |
|---|---|---|
GET /api/analytics/events |
analytics:read |
Count tracked analytics events per event name. Filter with ?event= (repeatable), source, subject_type, subject_id, from and to. Returns { counts }. |
POST /api/analytics/events |
analytics:write |
Record a batch of analytics events for the business (same shape as the public tracking endpoint). Returns 202 with the record result. |
GET /api/analytics/funnel |
analytics:read |
Funnel over tracked events: steps (required, 1 to 8 event names in order, repeated ?steps=a&steps=b or comma separated ?steps=a,b) and how many reached each, with conversion from the first and previous step. Optional source, subject_type, subject_id, from/to (ISO datetimes) and count_by: session (default), visitor or event. |
/api/api-keys 5
| Operation | Scope | What it does |
|---|---|---|
DELETE /api/api-keys |
|
Revoke an API key by id. User sessions only. |
GET /api/api-keys |
|
List the business's API keys (prefix, name, scopes, expiry, default site; never the full key). User sessions only. |
PATCH /api/api-keys |
|
Pin or clear the default QuickCapture site (business location) for an API key. User sessions only. |
POST /api/api-keys |
|
Mint a secret (mb_live_) or publishable (mb_pub_) API key. Returns the full key once; it cannot be retrieved again. User sessions only. |
GET /api/api-keys/activity |
|
"Agent activity": the writes API keys made in this business, newest first, each described in plain English ("Created bill: Home Depot, $6.00") with a link to the record when one exists. ?keyId=<api_keys.id> one key only ?before=<created_at> page older rows ?limit=50 1-200 userOnly, like the rest of key management: an agent does not audit itself. |
/api/assets 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/assets |
ledger:read |
List all assets for the current business. |
POST /api/assets |
ledger:write |
Register a fixed asset (cost, salvage, useful life, depreciation start). The cost is purchase_price and the date acquired_date (unknown keys are a 400). Dates are YYYY-MM-DD and salvage_value may not exceed purchase_price (400). Registering does not record the purchase: warning asset_cost_not_in_ledger says when the asset account's posted balance is below its active assets' cost. asset_account_id (fixed_asset), accumulated_depreciation_account_id and expense_account_id are the asset's GL accounts; omitted ones default to the chart's only account of that kind (null when there are several: PATCH them later). The daily cron posts monthly depreciation into them; POST /api/assets/{id}/depreciation drafts catch-up months and POST /api/assets/{id}/dispose drafts a sale or retirement. |
GET /api/assets/{id} |
ledger:read |
Asset detail with linked entries, depreciation and accounts: accumulated_depreciation and net_book_value come from posted depreciation-linked entries (what the ledger carries); scheduled_accumulated_depreciation, unposted_periods and unposted_amount show what the straight-line schedule expects by today that is not posted yet; drafted_amount is depreciation waiting in drafts and undrafted_periods what POST /api/assets/{id}/depreciation would still draft. accounts lists the asset, accumulated depreciation and expense accounts in use (source asset = set on the asset, linked_entries = what the asset's own depreciation entries used, default = a chart account named for depreciation); when one cannot be resolved, accounts.missing and warning asset_accounts_missing name the PATCH. depreciation.automation says whether monthly depreciation posts by itself (automatic, blocked_by, how_it_works). Warning recurring_entry_depreciates_asset names an active recurring journal entry that also credits this asset's accumulated depreciation (it would double the month). |
PATCH /api/assets/{id} |
ledger:write |
Update an asset. asset_account_id (fixed_asset), accumulated_depreciation_account_id and expense_account_id set its GL accounts (400 account_wrong_type when one does not fit, null clears it). To sell or retire an asset use POST /api/assets/{id}/dispose, which drafts the entry. |
POST /api/assets/{id}/depreciation |
ledger:write |
Draft the asset's depreciation through a date: one draft per month whose period ends on or before through and is neither posted nor already drafted, Dr the asset's expense account / Cr its accumulated depreciation account, each linked to the asset (link_type depreciation) so it counts once posted. Closed months are skipped and listed in skipped_closed (next_actions then names the reopen call; a disposal does not wait for them). preview: true returns the months without drafting. Accounts are the asset's own, else the ones its linked depreciation entries used, else a chart account named for depreciation; 409 asset_accounts_missing when none resolves (PATCH /api/assets/{id}). 409 depreciated_by_recurring_entry when an active recurring journal entry already credits the asset's accumulated depreciation account (it would double the month): stop that template, or send allow_recurring_overlap: true once the owner confirms it is not depreciating this asset; preview answers with the overlap in warnings. Drafts are for the owner to post. |
POST /api/assets/{id}/dispose |
ledger:write |
Sell, retire or write off an asset: drafts Dr deposit_account_id (proceeds) + Dr accumulated depreciation, Cr the asset's cost account, and the difference to gain_loss_account_id (gain credited, loss debited), links the draft (link_type sale) and sets status sold (proceeds > 0) or retired, plus disposed_at and disposal_proceeds; the cron stops depreciating it. 409 depreciation_due lists months through the date in open periods not yet posted or drafted (run POST /api/assets/{id}/depreciation first, or allow_unposted_depreciation: true); months in closed periods do not block it and come back as a warning, the gain or loss absorbing them. An asset entered by mistake, with nothing posted, is voided with POST /api/assets/{id}/void instead. 400 gain_loss_account_required lists other income/expense candidates. preview: true changes nothing. The owner posts the draft. |
POST /api/assets/{id}/link-entry |
ledger:write |
Link a posted journal entry to an asset (acquisition, depreciation, repair, sale...). Drafts are refused (409 entry_not_posted). Depreciation links count toward the asset's accumulated depreciation and net book value at amount, which defaults to the entry's total debits. |
POST /api/assets/{id}/void |
ledger:write |
Void an asset entered by mistake: status becomes voided (the record stays, reason kept in metadata) and depreciation, disposal and the year-end check skip it. 409 has_posted_entries when a posted entry is linked: the asset is on the books, so dispose of it (POST /api/assets/{id}/dispose) or reverse those entries instead. Unposted drafts linked to it come back in linked_drafts, with next_actions to discard each (DELETE /api/drafts/{id}). |
/api/audit-log 1
| Operation | Scope | What it does |
|---|---|---|
GET /api/audit-log |
settings:read |
Audit trail, newest first: who posted, edited, deleted or reversed journal entries, recorded or refunded invoice payments, issued, applied, refunded or voided credit memos, approved or paid bills, completed, reopened or cancelled bank reconciliations, closed or reopened months, invited team members. Each row has action, resource_type, resource_id, metadata (amounts, document number, posted_entry_id) and actor (the X-Workforce-Employee or API key name, else the user's email). Filters: limit (1-500, default 100), resource_type, resource_id, action, from, to (YYYY-MM-DD); an unknown or malformed filter is a 400. |
/api/automations 25
| Operation | Scope | What it does |
|---|---|---|
GET /api/automations |
automations:read |
List the automations available to this business (scheduled jobs such as recurring invoices, digests and outreach sends) with on/off state, schedule, next run, last run and any active run (their output is a short preview: output_truncated, output_length, and full_output naming the GET /api/automations/runs call with the full text). Each says whether it sends to people (outward) or deletes data (destructive). Returns { automations }. |
PATCH /api/automations/{key} |
automations:write |
Turn an automation on or off, or change its schedule (five-field cron; null resets to the default). Turning on one that permanently deletes data answers 409 with the reason until repeated with confirm: true. A newly enabled automation never runs windows from before it was turned on. Returns { setting }. |
POST /api/automations/{key}/run |
automations:write |
Queue an automation to run now, whether or not it is switched on. One that sends to people or deletes data answers 409 describing the effect until repeated with confirm: true; one already queued or running answers 409. Script automations (biz.*) take an optional payload, their input for this run; managed ones also take dry_run: true to test the newest uploaded version. Returns 202 { run }; follow it with GET /api/automations/runs. |
DELETE /api/automations/business/{name} |
automations:write |
Remove one of this business's script automations from the schedule. Its run history stays; its files on the server are not touched. Refused (409) while it is queued or running. |
PUT /api/automations/business/{name} |
automations:write |
Register or update one of this business's own script automations (key biz.<name>). The program is a file (run.mjs, .py or .sh) in /srv/businesses/<slug>/automations/<folder>/ on padrinovps; the worker runs it as the business's own user with { payload, state } as JSON on stdin. It starts switched off. Returns { automation, next_actions }. |
PUT /api/automations/business/{name}/files |
automations:write |
Upload a script automation's program as a new version: { files: { "run.mjs": "..." }, note }. Replaces the whole file set (send every file); at most 25 files, 512KB, plain source types. The version is a draft and does not run at all (no test runs) until Padrino (the platform) reviews and approves it; after approval, POST /api/automations/biz.<name>/run { dry_run: true } tests it without credentials. Program contract: stdin { payload, state, run }, stdout = output, exit 0 = success, new state as JSON to $STATE_FILE. Env on approved (non-dry) runs: PADRINO_URL (API base) and PADRINO_API_KEY (the business's automations key: every grantable scope except auth:email-code and settings:reveal; send as Authorization: Bearer), SECRET_<SLUG> per vault secret, RUN_ID, BUSINESS_SLUG; dry runs get DRY_RUN=1 and no credentials. Calling the Padrino API with that key is the intended way for a script to read and write business data. Returns 201 { version, status, next_actions }. |
PUT /api/automations/business/{name}/state |
automations:write |
Replace a script automation's stored state, its memory between runs (e.g. which businesses a scanner already looked at). Send { state: {} } to reset it so the next run starts fresh. Returns { automation }. |
GET /api/automations/business/{name}/versions |
automations:read |
List a script automation's uploaded versions, newest first (without file contents): status draft (waiting for approval), approved (what runs), superseded or rejected. Returns { approved_version, versions }. |
GET /api/automations/business/{name}/versions/{version} |
automations:read |
One version of a script automation, including its files ({ path: source }). Returns { version }; an unknown version is 404, a non-numeric one 400. |
GET /api/automations/lanes |
automations:read |
This business's automation lanes (default, outward, slow, plus any it created): how many jobs run at once in each, whether it is paused, and what is queued or running in it; plus the business's worker check-ins. The worker starts on its own when a job is queued and stops when idle. Returns { lanes, workers }. |
DELETE /api/automations/lanes/{name} |
automations:write |
Remove a lane this business created. Refused (409) for the built-in lanes and while an automation is in it. Returns { ok }. |
PUT /api/automations/lanes/{name} |
automations:write |
Create a lane or change one: { concurrency (1 to 3 jobs at once), paused }. Pausing starts nothing new in the lane (queued jobs wait; a running job keeps going until it finishes or is cancelled with POST /api/automations/runs/<id>/cancel). Move a script into a lane with PATCH /api/automations/biz.<name> { lane }. Takes effect within 30 seconds. Returns { lane }. |
GET /api/automations/reviews |
automations:write |
Platform only (people in the platform owner's business): every uploaded automation version waiting for review, across all businesses, oldest first. Returns { reviews }. |
GET /api/automations/reviews/{id} |
automations:write |
Platform only: one uploaded automation version with its files, for review. Returns { version }. |
POST /api/automations/reviews/{id}/approve |
automations:write |
Platform only: approve an uploaded automation version so it is what runs for its business (scheduled and manual runs, with that business's full-access automations key). Supersedes the previously approved version and older drafts. Returns { version }. |
POST /api/automations/reviews/{id}/reject |
automations:write |
Platform only: reject an uploaded automation draft so it can never be approved or tested. Returns { ok }. |
GET /api/automations/runs |
automations:read |
Automation run history, newest first: queued, running, succeeded, failed, timed_out or cancelled, with timing, the job's HTTP status, output and error. Filter with ?key= and ?status=; ?limit= up to 200. Returns { runs }. |
POST /api/automations/runs/{id}/cancel |
automations:write |
Cancel an automation run. A queued run is cancelled at once; a running one is flagged and its worker stops waiting for it within about 15 seconds (work the job already did is not undone). Returns { run }. |
POST /api/automations/worker/business-key |
automations:wake |
Padrino's waker only: mint a worker key for one business (automations:run, owned by that business) the first time its worker starts, revoking its older worker keys. The plaintext is returned once. Returns { key }. |
POST /api/automations/worker/claim |
automations:run |
Worker only: claim the oldest queued automation run in the given lanes (each run goes to exactly one worker; never two of the same automation at once). Returns { run, execute } or { run: null } when nothing is due. |
POST /api/automations/worker/finish |
automations:run |
Worker only: record how an automation run ended (succeeded, failed, timed_out or cancelled) with the job's HTTP status, output and error. Only the worker holding the run's lease can finish it. Returns { run }. |
POST /api/automations/worker/heartbeat |
automations:run |
Worker only: renew the lease on a running automation run. Returns { alive, cancel_requested }; the worker stops when cancel_requested is true or alive is false. |
POST /api/automations/worker/lanes |
automations:run |
Worker only: check in (recorded for the Workers section of /automations) and get the lanes to serve: every lane that is not paused, with how many jobs may run at once. Returns { lanes }. |
POST /api/automations/worker/tick |
automations:run |
Worker only: queue every scheduled automation window that is due (latest window only, never twice) and time out runs whose worker stopped renewing its lease. Safe to call from several workers. Returns { queued, expired }. |
POST /api/automations/worker/wake |
automations:wake |
Padrino's waker only: queue every business's due scheduled script runs, then list the businesses whose worker must be running (a script run is waiting in a lane that is not paused). Returns { businesses: [{ business_id, slug, queued }], scheduled }. |
/api/balances 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/balances |
ledger:read |
Loan balances: what the business owes on each loan account (payables) and what it is owed on loans it made (receivables), one row per loan account and counterparty, so the rows add up to the loan accounts on the balance sheet. counterparty is the entries' counterparty label; when a loan account's lines carry none (e.g. a loan booked from the bank feed) the row is named after the account and counterparty_source is account. account gives the loan account. amount includes drafts; posted_amount is on the books and draft_amount is waiting for the owner to post. ?search=Thistle narrows to rows whose counterparty or loan account name or code contains it (totals then cover those rows); any other query key is a 400. For a single account use GET /api/ledger/search?account_code=... |
POST /api/balances/{counterparty} |
ledger:write |
Draft a loan line with a counterparty (borrowed, lent, repaid). Books to account_id, else the loan account this counterparty already uses, else the only loan account; several loan accounts and no history is 409 ambiguous_loan_account with candidates, and no loan account at all is 422 no_loan_account; both carry new_account and, first in next_actions, the POST /api/accounts that gives a new counterparty its own loan account ('<name> Loan'; then resend with its id as account_id). A payment line whose principal is more than the loan account's balance (drafts included) is 409 payment_exceeds_balance { balance, posted_balance, draft_balance, excess, next_actions } (the principal + interest split, GET /api/balances, the resend with confirm_exceeds_balance: true for a confirmed overpayment). cash_account_id picks the bank (defaults to the main cash account), date defaults to today. An entry already drafted or posted with the same bank amount on the same loan and bank accounts within 5 days (a payment the bank feed recorded) is 409 possible_duplicate { possible_duplicate_of [{ entry_id, status, date, same_date, bank_row_linked }], next_actions }; resend with confirm_duplicate: true only for a separate line. 409 ambiguous_loan_account also carries the resend body for each candidate. For a lender's payment with interest, amount is the principal and interest the interest part (books to interest_account_id, else the interest account this counterparty used before, else the one whose name names it, else the only interest account when it is a general one; otherwise 409 ambiguous_interest_account with candidates, each marked kind general | this_counterparty | named_for_another (general first), a resend next_action per candidate and, when no general interest account exists, new_account and first in next_actions the POST /api/accounts that creates 'Interest Expense' ('Interest Income' for owed-to-me); no interest account at all is 422 no_interest_account with the same create step); the bank line is their sum. A new lender's ambiguous_loan_account leaves loans from the owner out of candidates. Returns the draft id and the accounts used. |
/api/bank-feed 23
| Operation | Scope | What it does |
|---|---|---|
DELETE /api/bank-feed/accounts/{id}/map |
bankfeed:write |
Clear a bank account's or imported feed's chart account mapping. |
POST /api/bank-feed/accounts/{id}/map |
bankfeed:write |
Set which chart account (asset or liability: checking, savings, credit card) a bank feed posts through. id is a Plaid bank account id or an imported statement's feed_id. Drafts and matches from the feed's rows use this account as the bank side. Moving an imported feed whose rows are already drafted or matched to a different account is a 409 feed_has_booked_rows (those rows keep the old account): ask the owner, then resend with confirm_account_change: true. |
GET /api/bank-feed/backfill-status |
bankfeed:read |
Am I caught up? Counts every non-pending bank row: total, classified (drafted, matched or ignored), needs_review (needs_clarification + unmatched + anything else not worked), needs_clarification, unmatched, ignored, future_dated (dated after today), progress_percent (rounded down, so 100 means done), is_clean, by_month [{ month, total, classified, needs_review, is_clean, has_future_rows?, future_dated? }] and next_actions for what is left. |
POST /api/bank-feed/bulk-resolve |
bankfeed:write |
Resolve several unmatched or needs_clarification bank rows at once: business or personal creates a draft per row (account_id is the category for all of them), ignore marks them ignored. results lists each row's outcome and error code. create_rule saves a merchant rule (with account_id as its category) when at least one row resolved. |
GET /api/bank-feed/connections |
bankfeed:read |
List all Plaid connections for the current business. |
DELETE /api/bank-feed/connections/{id} |
bankfeed:write |
Disconnect a bank connection. Removes Plaid item and marks as disconnected. |
POST /api/bank-feed/connections/{id}/register-webhook |
bankfeed:write |
Updates the webhook URL on an existing Plaid Item. Use this once to fix connections created before webhook URL was configured. |
POST /api/bank-feed/connections/{id}/sync |
bankfeed:write |
Manually trigger a transaction sync for a connection. |
POST /api/bank-feed/exchange-token |
|
Exchange Plaid public token for access token after user completes Link. Stores encrypted access token and creates connection + accounts. userOnly: only a completed browser Plaid Link session can produce the public_token this accepts. |
POST /api/bank-feed/import |
bankfeed:write |
Import a CSV, OFX or QBO bank statement file as multipart/form-data (file, column_mapping for CSV; ?feed_id= to target an existing feed; ?chart_account_id= for the bank-side GL account, whose existing feed is used when feed_id is omitted, with duplicates checked across all its feeds). JSON clients use POST /api/bank-feed/import/json. |
POST /api/bank-feed/import/json |
bankfeed:write |
Import bank transactions as JSON from exactly one source: a CSV/OFX/QBO file's text ({ format, content, column_mapping? }: a CSV's date, description, amount or debit + credit, balance and id columns are read from its header row when column_mapping is omitted and returned as column_mapping with column_mapping_detected: true; 400 missing_column_mapping lists the headers when they cannot be read), already-parsed rows ({ transactions: [{ date, amount, description, transaction_id?, balance? }] }; balance is the statement's running balance after the row, which gives the rows the same statement check and balance_breaks as a CSV balance_column), or a PDF or photo statement already stored: document_id (POST /api/documents/upload) pm_attachment_id (a file attached to a task, from GET /api/pm/tasks/{id}/attachments) or pm_comment_attachment_path (a file the owner attached to a comment: attachments[].path from GET /api/pm/tasks/{id}/comments). A file is read by the vision model, so send dry_run: true first: nothing is imported and the response has the rows plus statement { period_start, period_end, beginning_balance, ending_balance, transactions_total, balance_ties, difference, account_kind (credit_card on a card account), amount_owed (credit card accounts only: the ending balance as the positive amount owed, the statement_ending_balance POST /api/bank-reconciliation takes; ending_balance keeps the rows' sign, negative when owed; a card statement with a single balance row is read as continuing from what the card owed before it, the posted books the day before or the last completed reconciliation, else a positive balance is the amount owed) }; balance_ties false means a row was missed or misread; a CSV gets the same statement when column_mapping names its running balance column (balance_column), plus balance_breaks [{ row, date, expected_balance, file_balance, difference }] where the balance jumps and misread_balances [{ row, file_balance, balance_from_rows }] (a break of d on one row and -d on the next: that row's printed balance was misread, the amounts are fine); balance_check (dry run, when balance_ties is false) says which: explained misread_balance means the next_action body already carries the balance the rows give, so import it; explained null lists unexplained_breaks with a message and ask_owner (the top-level ask_owner after an opening_gap one, asks[] when both) and its next_action body only re-runs the dry run: add the missing row or fix the amount first; warning closed_period { books_closed_through, rows_in_closed_months, first_date, message, next_actions (one reopen per closed month from the earliest row's month through books_closed_through, latest first; reopening only the last leaves the earlier months locked) } when rows the import would write fall in closed months (rows already imported are not counted; the import returns the same object as closed_period, its message also in warning, and the reopen in next_actions: tell the owner, their drafts post only with confirmClosedPeriod: true or after the reopen); opening_gap { as_of, books, statement, difference, ledger_lines_through_as_of, message, ask_owner, next_actions } (dry run and import; on a card account books, statement and difference are in owed terms, positive = owed, whichever side the rows were signed from, so its opening draft credits the card; with ask_owner at the top level and its draft first in next_actions) when the account has no completed reconciliation and the posted books the day before the statement starts differ from the statement's beginning balance (as_of is that day and the date the opening draft carries: the day before a PDF's printed period, or for a CSV or rows the last day of the month before the first row, the beginning balance date POST /api/bank-reconciliation reads; when rows already on the account (an earlier import) or ledger lines no bank row stands for (a row not yet processed and a line of its amount within 5 days are the same money, counted once) fall between that day and the statement's first row, the statement's beginning balance is worked back over them and compared on as_of, which moves to the day before the month of the account's earliest row, so a follow-on import asks the same opening as the first import (or none), never the new statement's balance again: projected_from { as_of, balance_date, statement_beginning_balance, rows, rows_total, first_row_date, lines, lines_total, beginning_balance } shows the arithmetic; closed_period { books_closed_through, confirm_closed_period } when as_of is in a closed month): usually a missing opening balance (when the business notes say full bookkeeping, ask_owner asks it as a fact: "Your statement shows $1,250.00 on October 31, 2026. Is that what was in the account the day before you started?"), so confirm it with the owner and draft it before sorting the rows; when that opening is already drafted (on or before as_of, against Opening Balance Equity, for the difference) it carries pending_opening_draft { entry_id, date, amount } and its first next_action posts that draft (POST /api/drafts/{id}/post, with confirmClosedPeriod: true when as_of is in a closed month) instead of drafting another; when the books are higher because the opening is posted twice it carries duplicate_openings [{ entry_id, duplicate_of, date, amount }] with next_actions reversing the duplicate on its own date; reconciled_period { reconciliation_id, statement_ending_date, rows_in_reconciled_period, message, next_actions } (dry run and import, the import also in warning) when rows it would write (the dry run counts all rows only when every one is already imported) are dated on or before the account's last completed reconciliation: duplicates of cleared lines or lines of a statement already signed off, so ask the owner before processing them; but when the statement begins at that reconciliation's ending balance it carries posted_after_statement: true (and statement_beginning_balance): the rows were posted by the bank after that statement was cut, so they are new (never ignore them), the reconciliation stays as it is, and their drafts post with confirmReconciledPeriod: true on their bank date and clear on the next reconciliation; a row keeps the transaction_id it was sent or read with. Then import the returned transactions as rows (no second read of the file). 404 document_not_found / attachment_not_found, 415 unsupported_file_type, 413 file_too_large, 422 statement_unreadable (the reader answered but not with statement rows) or no_transactions (read, no activity); 503 reader_unavailable means the reader itself failed and the file was not read: retry it later (retry_after_seconds), don't ask the owner for another format. Same pipeline and dedup as the multipart upload. Pass chart_account_id (the checking, savings or card account) so drafts and matches know the bank side; without feed_id the rows go into that account's existing feed (feed_reused: true; its latest CSV/OFX/QBO/API feed, never a Plaid feed) and a new feed is created only when the account has none (feed_created: true); feed_id names a feed explicitly (also feed_reused: true; GET /api/feeds lists them with their account and date range). The dry run returns that feed_id, would_import, duplicates, already_imported and possible_duplicates exactly as the import would count them, plus duplicate_warning, and its next_action body carries feed_id and the rows to send (already imported rows left out, and the repeats of rows already on the account, possible_duplicates with duplicate_of and keep: false, unless statement_account_check ties with them in; the why names the rows left out), plus statement_end { period_end, ending_balance } when the rows left out run past the last row sent: send it as given, so the import knows where the whole file ends (same-day posted_after_statement rows then point at POST /api/bank-reconciliation through that date, not a reopen). Dedup spans every feed of the account: a row whose transaction_id is already on any feed of the same chart account, or repeated within the batch, counts in duplicates, is listed in already_imported [{ row, date, amount, description, existing_id, existing_feed_id, matched_by }] and is not imported again. Rows are strict: date, amount, description, transaction_id, balance only (an unknown key such as id or fitid is a 400 naming the real field). Rows with an impossible or future date (more than a day past the business's today, in its time zone: the same last date a payment linked to the row may carry) refuse the whole batch with 400 invalid_rows listing them { row, path, date, reason }, in a dry run too. A row without transaction_id is skipped when the account already has one with the same date, amount and description. Descriptions compare ignoring case, spacing and punctuation (# * - . ,), so the PDF and the CSV of the same days skip each other. Rows imported with the same date and amount as another row of the account and a similar description (one containing the other, or mostly the same words) come back in possible_duplicates [{ id, duplicate_of, date, amount, description, keep }] with a warning (the dry run lists the same rows as { row, duplicate_of (row already on the account) or duplicate_of_row (a row of this batch), keep }): ask the owner whether it is a double import (ignore, or leave out, the row with keep: false; a row repeating one already on the account always has keep: false) or, for money out, a double charge; for deposits, a second payment. A pair inside one batch is listed both ways; when the file's running balance ties (balance_ties true) only with both rows, both carry keep: true and confirmed_by_balance: true in the dry run and the import (two real charges, not a double import; never ignore either). When the statement's balances are known (PDF, photo, or CSV with balance_column) and the account already has rows in its period, statement_account_check { beginning_balance, rows_already_on_account, already_on_account_total, new_rows_total, balance_after_import, ending_balance, difference, ties, message } compares the statement with the account's rows plus these (dry run and import; after the import, new_rows counts the rows imported now and rows_already_on_account the ones that were there before): ties false while balance_ties is true means rows already on the account repeat rows of this file under other wording, and the difference is the amount that would be booked twice. |
POST /api/bank-feed/link-token |
|
Create a Plaid Link token for the frontend to initialize Plaid Link. Requires Pro subscription, bank feed auto-import is a paid feature. userOnly: the token it mints is only usable by the browser Plaid Link widget, so there is no headless caller to serve. |
GET /api/bank-feed/merchant-groups |
bankfeed:read |
Returns bank transactions grouped by merchant name with counts and totals. Query params: status, feed_id, from_date, to_date, min_count |
POST /api/bank-feed/process |
bankfeed:write |
Classify unmatched bank rows. A row already in the books (exactly one draft or posted entry carrying its amount on this bank account within 5 days whose description, counterparty or reference names who the bank text names) is linked to that entry (outcome matched, matched_entry_id, no new draft); amount and date alone never link: same-amount entries that do not name the payer or payee, or several that do, -> needs_clarification naming them (possibly_recorded_as) with one match next_action each; one recorded from another own account's side (other_money_accounts: a card payoff or transfer drafted from checking) leads with ask_owner and possibly_transfer: true, and no client-credit, income or owner-capital actions, and entries cleared on a completed reconciliation of the account are not candidates. A row with the same bank account (any of its feeds), date, amount and description as a row already handled (or earlier in this run) -> needs_clarification (counts.duplicates) instead of a second draft: money out asks double import or charged twice?; money in that fits another open invoice of the customer asks second payment or double import? with invoices[] and next_actions (the invoice payment, and ignore), never a create-draft to income that would leave the invoice open. Deposits of one run share the open invoices: rows naming an invoice number, then rows equal to an open balance, claim first, and an invoice claimed by one row is not offered in full to another (the reason names it: also matched X, claimed by bank row Y); a row linked to an entry already in the books (outcome matched) claims nothing, so a same-amount twin after it is offered the invoice's open balance (invoice payment first, ask_owner naming what is still open) before ignore; a row whose only candidates are weak name matches (invoice_match weak) claims nothing either, so the next row sees the invoice's full balance. A deposit that may pay two or more invoices with nothing saying which -> needs_clarification with invoice_match: several, ask_owner, each invoice's payment next_action and an income create-draft; a weak candidate beside a stronger one is left out (the reason names it). Money in that pays an open invoice, sent or draft (never marked sent, e.g. a paper invoice handed to the client) (the invoice number in the bank text, or a customer it names whose open balance covers the amount; by customer a draft only when none of their sent invoices fit; a card processor's payout (SQUARE INC DEPOSIT) pays by customer only an invoice whose balance equals it, never part of one; a named customer paying more than their open balance gets the invoice with excess and ask_owner, keep the extra as credit or send it back?, and the next_action carries excess_to_customer_credit: true) -> outcome invoice_payment (counts invoicePayments) with invoices[] (id, invoice_number, customer_name, balance_due, partial, matched_by, status sent | draft) and next_actions: POST /api/invoices/{id}/payments { amount, payment_method, payment_date, bank_transaction_id } records the payment on the invoice (revenue at payment) and links the row (paying a draft in full marks it paid; a partial payment on a draft first carries a POST /api/invoices/{id}/send next_action, since a draft takes only a full payment); no draft is made, and the classifier never drafts a deposit against Accounts Receivable. One deposit that is exactly the sum of several of a customer's open invoices (named by number, a bare second number like 'INV 0162 0163', or by the customer) marks each pays_together (no excess: nothing is overpaid), ask_owner asks to record it against them, and next_actions are ordered steps: the whole deposit on the first invoice with bank_transaction_id and excess_to_customer_credit: true (links the row, keeps its date), then { amount, from_customer_credit: true } on each other invoice; never refund that credit. When the deposit is more than each matched invoice and exactly one set of them (2 or more) adds up to it, only that set is offered, pays_together (the others are left open); when several sets do, every invoice carries exact_sets and ask_owner asks which set it pays, with no overpayment or refund wording. A customer's invoice dated up to 31 days after the deposit counts when it completes such a set with the invoices issued by then (billed late: the client paid an accepted estimate before the owner invoiced it): it carries issued_after_deposit with its issue_date, ask_owner asks whether the money was for it, and its step pays it from the credit on its issue date; if the owner says no, record the deposit on the earlier invoice alone with excess_to_customer_credit. A deposit short of the invoice balance whose customer holds open credit (a retainer or earlier overpayment in Customer Deposits) is the client paying net of it: the invoice carries customer_credit { open_credit, applies, settles } (settles: the deposit plus that credit pays it in full), ask_owner says the credit covers the rest, and next_actions are the bank payment, then { amount: applies, from_customer_credit: true } on the same invoice. After a payment with excess_to_customer_credit, the first next_action pays the open invoice (or set) whose balance equals the new credit exactly, ahead of the oldest. Money out that pays an open bill (the bill number in the bank text, or a vendor it names by a distinctive word whose open balance covers the amount) -> outcome bill_payment (counts billPayments) with bills[] (id, bill_number, vendor_name, balance_due, partial, matched_by) and next_actions: POST /api/bills/{id}/payments { amount, payment_method, paid_at, bank_transaction_id } posts Dr Accounts Payable / Cr the bank (Cr the card, payment_method card, for a row on a credit_card account) and links the row; no draft is made (a draft to the vendor's expense account would book the approved bill's expense twice). A row that pays one bill short by no more than the vendor's open vendor credit (420.00 bill, 270.00 paid, 150.00 credit) is that bill paid less the credit: still bill_payment, with bills[].net_of_vendor_credit (the credit used), vendor_open_credit and next_actions POST /api/vendor-credits/{id}/apply { bill_id, amount } first, then the payment, so the bill closes instead of staying open for the credit amount (never asked part payment or separate purchase, unless a card swipe). A bill issued after the row's date is never offered (the money left before the bill existed). A bill found only by the vendor's name that the row pays only part of, or any vendor-name match on a card swipe at the counter (POS, DEBIT CARD PURCHASE, CHECKCARD), is needs_clarification instead, with bills[] (plus issue_date), ask_owner (part of the bill, or a separate purchase?) and next_actions: the bill payment and create-draft { account_id, vendor_id } as its own purchase (the purchase first for a card swipe); a card swipe never claims a bill by the vendor's name in the run, so it does not shrink the balance another row sees. Money out more than all the open bills of the vendor it names together (700.00 with one 600.00 bill open: a deposit or rounding up, or a separate purchase) is needs_clarification too, never a draft to the vendor's expense the bill already booked: bills[] carry excess, ask_owner asks which, and next_actions are the bill payment of the whole row with excess_to_vendor_credit: true (pays the oldest bill, holds the extra as an open vendor credit, links the row; apply it to their other bills) and create-draft for a separate purchase. Money in from that vendor in the same run that says refund and fits the extra is needs_clarification with overpaid_by_row { id, excess } and next_actions: that payment first, then GET /api/bank-feed/transactions/{id}/matches, whose vendor_credits[] has the credit's refund call for the row. Payments out of one run share the open bills the same way: a bill claimed by one row is not offered in full to another, and a row whose only bill another row pays (a second same-amount ONLINE BILL PAY to the vendor) -> needs_clarification with ask_owner (paid the vendor twice, or the same payment imported twice?), bills_paid_by_other_rows [{ bill_id, bill_number, vendor_id, vendor_name, claimed_by_row_id, claimed_amount }] and next_actions: POST /api/bills/{id}/payments for another open bill of that vendor it fits, POST /api/bank-feed/transactions/{id}/ignore (a double import), and create-draft { account_id, vendor_id } to a prepaid / vendor deposits asset (paid twice; preceded by POST /api/accounts { subtype: prepaid } when the chart has none), never to an expense the bill already booked. Rows with an invoice or bill match also list possibly_recorded_as when same-amount entries exist that do not name the payer (entries of another customer or vendor than the bank text names are left out; on a needs_clarification row they carry counterparty_mismatch: true). Money in whose text says a client is paying ahead (retainer, deposit other than the bank's own MOBILE / ATM / DIRECT DEPOSIT label, prepayment, prepaid, down payment, advance payment) and pays no open invoice -> outcome customer_credit (counts customerCredits; customer_id and customer_name when the text names a customer on file) with no draft, since it is the client's credit in Customer Deposits until invoiced, never revenue: next_actions POST /api/customers/{customer_id}/credits { amount, payment_method, bank_transaction_id } posts Dr the bank / Cr Customer Deposits and links the row (with no customer named, GET /api/customers first). Money out to a customer the text names (whole name; or a word of it when the text says refund or return and names no vendor) who holds an open credit memo, when the text names the memo (CM-0047), or the memo has exactly this much credit left and the text names no vendor, or the text says refund or return (refund, reimburse, return, returned, overpaid, credit memo) and the memo covers it -> outcome credit_memo_refund (counts creditMemoRefunds; customer_id, customer_name, credit_memo { id, credit_memo_number, remaining_credit, matched_by memo_number | amount | covers, source_invoice_id }) with no draft, never an expense, contractor or owner draw: next_actions POST /api/credit-memos/{id}/refund { amount, payment_method, reference (a check's number), bank_transaction_id } posts Dr sales returns (and the memo's tax share) / Cr the bank, lowers the memo and links the row; rows of one run share a memo's credit. Money out to a customer named in the text who has open credit covering the amount (when the text says refund, reimburse, unused or overpaid, or names no vendor) -> outcome customer_credit_refund (counts customerCreditRefunds; customer_id, customer_name) with no draft, since a refund of a client's credit comes out of Customer Deposits, not an expense: next_actions POST /api/customers/{customer_id}/credits/{creditId}/refund { amount, payment_method, bank_transaction_id } (the oldest credit that covers it) posts Dr Customer Deposits / Cr the bank and links the row (GET the customer's credits instead when no single credit covers it). When only one word of that customer's name is in the text (customer_match: word, e.g. ZELLE TO MARGO LINDQVIST-ROWE for TEST Lindqvist Family) and it does not say refund, it is needs_clarification instead, with ask_owner (a refund to the client, or someone you paid?) and next_actions: the vendor calls (GET /api/vendors?search=, POST /api/vendors, create-draft { account_id, vendor_id }) first, the credit refund last. Money out to a customer the text names (also by a word of their name other than the lead word, the surname after an initial, when the text says refund and names no vendor, by the same rule as below: ZELLE TO T PENHALIGON-ROOK REFUND for TEST Tamsin Penhaligon-Rook, customer_match: word) that says refund (refund, reimburse, unused, overpaid), or that equals a payment on one of their paid invoices with their whole name and no vendor in the text, and no open credit of theirs covers it -> outcome invoice_refund (counts invoiceRefunds; customer_id, customer_name, refund_invoices [{ id, invoice_number, customer_name, refundable, matched_by invoice_number | payment_amount | refundable, payment_pending_on_row_id? }]) with no draft, never an expense or owner draw: next_actions POST /api/invoices/{id}/refund { amount, payment_method, reference (a check's number), bank_transaction_id } issues the credit memo, posts Dr the income accounts the invoice's lines credited / Cr the bank and links the row; when the invoice's payment is a deposit of this same run not recorded yet (payment_pending_on_row_id), its POST /api/invoices/{id}/payments comes first. Several invoices that could be refunded, or none, -> needs_clarification with ask_owner (which invoice? / what is it for?) and the refund call per invoice, or GET /api/invoices?customer_id= and create-draft. A refund of money held as the customer's credit (the text says retainer, deposit, prepayment, unused or credit balance) is never offered as an invoice refund by a matching payment amount, only by an invoice number in the text: when a retainer deposit naming that customer earlier in the same run (outcome customer_credit) covers it, the row is customer_credit_refund with credit_pending_on_row_id and next_actions POST /api/customers/{customer_id}/credits for that deposit first, then POST /api/customers/{customer_id}/credits/{credit_id}/refund (credit_id from the first call's response); otherwise needs_clarification with ask_owner (was the retainer recorded?) and GET /api/customers/{id}/credits. The excess of a deposit earlier in the same run that pays more than its one invoice (outcome invoice_payment with excess) counts the same way: money out that says refund to that customer and fits the excess -> customer_credit_refund with credit_pending_on_row_id (the deposit's row) and next_actions POST /api/invoices/{id}/payments { ..., excess_to_customer_credit: true } first, then POST /api/customers/{customer_id}/credits/{credit_id}/refund (credit_id is customer_credit.id from the payment's response), never a contractor, vendor or owner draw. A refund text names such a customer (open credit or credit pending in the run that covers it) also by a word of their name other than the lead word, the surname after an initial (ZELLE TO M ODWYER-TATE REFUND for TEST Margery Odwyer-Tate; customer_match: word), when that word is in no other customer's name (or the initial before it is theirs and no other sharer's: R QUARRINGTON-VALE) and the text names no vendor. Deposits of a run that become credits are shared the same way: one retainer or excess refunded by one row is not offered in full to another. Money in from a vendor the text names (its whole name or a distinctive word) who has an open vendor credit covering the amount (the one with exactly that amount left, else the oldest that covers it) -> outcome vendor_credit_refund (counts vendorCreditRefunds; vendor_id, vendor_name, vendor_credit { id, vendor_credit_number, remaining_credit }) with no draft, since the credit already took the expense back: next_actions POST /api/vendor-credits/{id}/refund { amount, payment_method, bank_transaction_id } posts Dr the bank / Cr Accounts Payable, lowers the credit and links the row; never create-draft it to the expense (it would be reduced twice and the credit stay open). A credit whose number the bank text carries (VC-0015) is picked before one found by the vendor's name. Money in naming a vendor credit whose refund of this amount into this bank account is already recorded is needs_clarification with possibly_recorded_as [{ id (the refund's entry), type, date, description, vendor_credit_id, vendor_credit_number, vendor_id, vendor_name, refund_id, refund_date, amount, entry_id, linked_row, feed_id }], ask_owner (the same refund imported from two sources?) and next_actions: ignore first, a new vendor credit with its refund only if the owner says it was paid again. A charged-twice duplicate whose twin row was drafted in the same run gets that draft's account_id and vendor_id in its create-draft next_action. Money in from a vendor the text names that says refund, reimbursement, return, rebate, overpayment, duplicate payment or credit, with no vendor credit covering it (and no customer named, or a customer sharing only one word with the text while the vendor's whole name is there, e.g. TEST PINECREST COWORK REFUND with customer TEST Pinecrest HOA): when a prepaid, other asset or non-invoice receivable account holds at least the amount posted for that vendor (lines on entries linked to it by vendor_id, labeled with its name, or with no label and a description naming it) -> outcome other_receivable (vendor_id, vendor_name, vendor_receivables [{ account_id, code, name, balance, lines }]) with no draft and next_actions create-draft { account_id: that account, vendor_id } first (Dr the bank / Cr the account), then the vendor credit; otherwise needs_clarification (vendor_id, vendor_name, ask_owner) with next_actions POST /api/vendor-credits { vendor_id, lines, refund: { payment_method, bank_transaction_id } } (the vendor refunding an expense) and create-draft { account_id, vendor_id } (money recorded as owed back); never income, a customer's credit or owner money. Money in whose text says refund, return, credit or reversal from a merchant that charged this same account before (no vendor or customer on file named; the charge booked, drafted or posted, up to 180 days back, at least the refund's amount, its text sharing the merchant's name words, e.g. TEST HOLLYBANK NURSERY LTD REFUND after TEST HOLLYBANK NURSERY LTD) -> outcome draft back against the account the charge's entry used now (Dr the bank / Cr that expense; counts drafts), with refund_of_charge { bank_transaction_id, name, date, amount, entry_id, account_id, account_code }, ask_owner (Hollybank refunded 20.00 on Nov 5; I put it back against Plants & Materials, OK?) and a PATCH /api/drafts/{id} next_action if the owner says otherwise; never a client's credit, income or owner money. Then a merchant rule (exact merchant name, or its merchant_pattern found in the description; ruleApplied, merchant_rule_id on the row) decides; else money out whose description names a vendor (its whole name, e.g. ZELLE TO MAYA R STUDIO for vendor Maya R Studio, or else the one vendor whose distinctive word it carries, e.g. ZELLE TO FERNHILL DESIGN for Fernhill Design Studio; a word that is in the name of the business's own bank, card or loan account does not count: WRENFIELD VISA XX4187 ONLINE PAYMENT is the card TEST Wrenfield Visa xx4187, not contractor TEST Wrenfield Studio; a row drafted to a bank, card, loan or equity account is linked to no vendor and asks no W-9) is drafted to that vendor's default_expense_account_id and linked to it (vendor_id, so a 1099 contractor's total counts it once posted; a 1099 vendor with no usable tax id (blank or a placeholder like 00-0000000), paid from a bank rather than a card, adds warnings [{ code: missing_tax_id, ask_owner, next_actions }] to the row, with that ask_owner and next_actions (PATCH /api/vendors/{id} { tax_id }, or a POST /api/pm/tasks to collect the W-9) on the row: ask the owner for the W-9 now, before the next payment; with no default account the row goes to needs_clarification naming the vendor); else the account a posted entry used for an earlier row with the same description (priorBookingApplied, counting only rows drafted that way: one refused as owner money, an equity account, is needs_clarification with prior_booking { applied: false, account_code, entry_id, date, bank_account_id } and no account_code); else money out whose text is the bank's own fee, after any leading date, reference or TEST (MONTHLY MAINTENANCE FEE, MONTHLY SERVICE FEE, SERVICE CHARGE, OVERDRAFT FEE, NSF FEE, WIRE FEE) -> draft to the bank fees expense account (Bank & Merchant Fees, Bank Fees & Charges; counts bankFees) without the classifier; else money in that is a card processor's payout (SQUARE INC DEPOSIT, STRIPE TRANSFER, SHOPIFY PAYMENTS, PAYPAL TRANSFER, TOAST, CLOVER; also when it shares one word with a customer, e.g. SQUARE DEPOSIT QUILLFEATHER for customer TEST Quillfeather Metrics: a payout batches card sales, so it is never that customer's money or the owner's) -> draft at net to the revenue role account (or, when this bank's earlier deposits went to another income account and never to the revenue role, that account) without the classifier, its row adding processor, ask_owner for the processor's statement and next_actions POST /api/bank-feed/transactions/{id}/processor-payout { gross, fees }, which rewrites the draft at gross with the fees as an expense (any drafted payout, by a rule or earlier booking too, carries the same); with no revenue role, needs_clarification with that call (plus income_account_id) and create-draft; else money in from another account (ONLINE TRANSFER FROM CHK XX9081, TRANSFER FROM SHARE, FROM ACCOUNT ENDING 1234) or whose text says it is the owner's (TRANSFER FROM OWNER PERSONAL SAVINGS, OWNER CONTRIBUTION, CAPITAL CONTRIBUTION, or a named person's personal account: MOBILE DEPOSIT FROM R ELLERBY PERSONAL SAVINGS, which keeps named_payer and lists the client calls last; the word owner is never a payer's name) -> needs_clarification with owner_money: true (false, with own_account_transfer: true, when the text names an account by kind and last4 that is not in the chart yet: add it first), ask_owner (your own money?) and next_actions create-draft { personal: true } (capital; { account_id } of the recommended contribution account when the chart has several) or { account_id } (loan from the owner, another business account), never income and never a customer's credit by itself even when the text carries a customer's word (possible_customer, their credit offered last); else money in naming a customer on file (no open invoice) -> needs_clarification with customer_id and the same credit next_action (paying ahead), or for work done without an invoice POST /api/invoices { customer_id, issue_date, lines } then POST /api/invoices/{invoice_id}/payments { amount, payment_method, bank_transaction_id } so the income is on the client's record (create-draft with the income account only to keep it off any client); when only one word of the customer's name matches (customer_match: word, often a surname the owner shares), the owner-money next_actions come first and the owner is asked client or own money, unless the text says the money pays for work (work_word: WEDDING, INVOICE, PAYMENT FOR, EVENT, SERVICES...) or the shared word sits only in another hyphenated name (ZELLE FROM HOLLIS-VANE WEDDING vs TEST Hollis Penhaligon-Rook: possible_customer and named_payer instead of customer_id, and no credit for that customer): then next_actions[0] is create-draft { account_id } to income_account (a payer not on file whose text has a work word gets the same lead, ahead of owner money) (the income account this bank's earlier deposits went to, why used; else the revenue role, why revenue_role, or why revenue_default when no account holds the role and the default stands in: assign it with PUT /api/accounts/roles after asking the owner), then the client's invoice + payment calls, then owner money; a deposit nothing explains, and a named payer not on file, also get that income account by id in their create-draft (it leads the client calls when this bank took income before); else money in whose text names a payer who is no customer on file after FROM (ZELLE FROM JUNIPER HALE, not a refund), or names one while another customer's sent invoice is open for exactly this amount -> needs_clarification with named_payer, ask_owner (who is it: a new client?) and next_actions GET /api/customers?search=, POST /api/customers { name }, POST /api/invoices then POST /api/invoices/{invoice_id}/payments with bank_transaction_id (work done, on the client's record once found or added), POST /api/customers/{customer_id}/credits (paying ahead) and create-draft { account_id } with the income account, never another customer's invoice payment; owner-money options only when owner_money_likely: true (the payer's name is in the bank account's or an owner's equity account's name, or the row is the first money on a new account with no open invoices), then create-draft to the owner contribution account leads and ask_owner offers your own money or a client paying you? (a payer or payee named in the text is never offered another party's same-amount bill or invoice); else, when the text names no customer or vendor at all (CHECK 1203, MOBILE DEPOSIT) and an open bill or sent invoice has exactly this balance (at most three; money out naming a merchant (TEST R142 CHEVRON 0442) is never offered a bill by amount, only a bare reference or cash withdrawal is; a bill must have owed exactly this on the row's date, so one lowered to the amount by a payment or vendor credit dated after the row is not offered), needs_clarification with bills[] / invoices[] (matched_by amount), ask_owner (is check 1203 the 180.00 to the vendor for that bill?) and the payment call first in next_actions (a check's number as the bill payment's reference), then the usual alternatives; cash taken out (ATM WITHDRAWAL, CASH WITHDRAWAL) instead asks what the cash was for, with the draw and petty cash calls first and the bill payment last: a guess from the amount alone, so ask before recording it, and never create-draft it to an expense or income the bill or invoice already booked; else the classifier. Business -> draft on the account picked (account_code; reason and confidence stored in ai_explanation / ai_confidence; when the classifier's reason names another chart account than its account_code, the draft goes to the one account named (the reason says so, confidence at most 0.6), or the row is needs_clarification naming both account_ids; a reason describing an account the chart does not have ("using the pet care supplies account" on 6158 Software Subscriptions) drafts nothing: needs_clarification with account_mismatch: true, ai_recommended_account_name, ask_owner and next_actions POST /api/accounts { code, name, type } then create-draft with the new account_id (the classifier's account last, only if the owner says so); a reason saying used on this bank for an account this bank's posted rows never used says so too (confidence at most 0.7); drafts made with an API key carry source api; money out drafted to a contractor expense account such as 5050 Contractors with no vendor named in the text also carries ask_owner for the contractor's W-9 and next_actions GET /api/vendors?search=, POST /api/vendors { name, is_1099: true } and PUT /api/ledger/{draft_entry_id}/vendor { vendor_id }: unlinked, it counts toward no 1099), personal money out -> owner draw draft, owner money (loan, capital, draw, money in from the owner), an unusable account pick or unclear -> needs_clarification with the reason and the create-draft call. Money in that nothing explains (no invoice, customer, rule or transfer text, e.g. MOBILE DEPOSIT) -> needs_clarification with next_actions for each answer: the owner's own money (create-draft { personal: true }, or one { account_id } per owner contribution account when the chart has several and none stands out), a client paying ahead (GET /api/customers?search=, then POST /api/customers/{customer_id}/credits), and payment for work done (create-draft { account_id } with the income account); on the first row of a new account the owner's money comes first. On a credit card account money in is most likely a merchant refund or a payment toward the card: ask_owner asks which (a store refunding a purchase, or a payment from which account?) and next_actions lead with create-draft to the refunded purchase's expense account, GET /api/bank-feed/transactions/{id}/matches and create-draft to the bank that paid the card; the client and owner-money calls come last. When automatic sorting itself is down (the AI provider fails and so does its fallback), a row that would go to the classifier is left open with outcome unclassified (counts unclassified; not queued for the owner, nothing guessed, retry process later or create-draft with the account), and warnings[] carries { code: classifier_unavailable, rows_affected, message, next_actions }. When sorting is up but fails on one row (the model ran out of tokens or wrote invalid JSON), that row is outcome unclassified too, but warnings[] says classifier_failed_for_row: retrying will not help, book it with its create-draft next_action. A row whose text names one of the business's own accounts by kind and last4 (TRANSFER TO SAV xx7791, TRANSFER FROM SAV xx7791) never depends on the classifier: with no chart account named with that last4 it is needs_clarification with own_account_transfer: true, ask_owner and a ready POST /api/accounts (subtype bank) then create-draft next_action, the owner's draw or contribution calls last; when the classifier fails on one whose account exists, the same question and its create-draft come instead of a bare account_code. Read warnings before rows. feed_id limits the run to one feed, transaction_ids to those rows. Rows already waiting on the owner are not processed again unless reclassify: true (the response's reclassified counts them): GET /api/bank-feed/transactions marks a stored question worked out under older rules clarification.stale: true and leads its next_actions with POST /api/bank-feed/process { transaction_ids: [id], reclassify: true }; call it before following the stored calls. Returns counts, warnings[], rows[] with each row's outcome and the classifier's reason and amount_mismatch (the same purchase probably already recorded at another amount: a capture, draft or unlinked posted entry on the same account within 5 days naming the same merchant, like a receipt drafted at its subtotal; the row is not drafted, ask_owner asks, next_actions PATCH the draft to the bank amount then match, else create-draft), possible_duplicate_of (the other row of a possible double import; only the first of a pair is drafted; the second row of a pair the statement's running balance confirms carries confirmed_by_balance: true, a real second charge with create-draft first and ignore last), and unmapped_skipped / unmapped_feeds for rows whose feed has no chart account (map it, then process again). An entry a void or reversal undid is never auto-linked unless the statement also shows its money going back (a bounced check): after voiding a payment, the real payment's row comes back as invoice_payment / bill_payment. Money out reading as a loan payment (LOAN PMT, LOAN PAYMENT, INSTALLMENT) that shares a lender word with one loan account is drafted to it with loan_payment: true, the interest ask_owner and the PATCH split template; several such loans is needs_clarification with a create-draft per loan. A row is never drafted to Accounts Payable (bills only); a draft moved to the account the classifier's reason names carries reason_account_mismatch { classifier_account_code, drafted_account_code }. |
GET /api/bank-feed/transactions |
bankfeed:read |
Imported bank transactions, newest first, with count. Filters: status (unmatched | draft_created | matched | ignored | needs_clarification), A needs_clarification row carries clarification { source, asked_at, stale, reason, ask_owner, next_actions, details }: the question POST /api/bank-feed/process asked (quote ask_owner to the owner; details holds named_payer, invoices, bills, possible_customer and the like) and the calls that settle each answer, so a later conversation picks up where process left off (source generic: no question was stored, the calls are the generic match / create-draft / ignore; stale: true when the question was worked out under older sorting rules, and next_actions[0] is then POST /api/bank-feed/process { transaction_ids: [id], reclassify: true }: call it first, the stored calls after it may be out of date), and open_task { id, title, status, related_type } when an open owner task (POST /api/pm/tasks related: bank_transaction or the bank account) is about it: read its comments (GET /api/pm/tasks/{id}/comments) for the answer instead of asking again. clarification and open_task are null on other rows. chart_account_id (one bank or card account's rows across all of its feeds), feed_id (GET /api/feeds lists each account's feeds), plaid_account_id, from_date and to_date (YYYY-MM-DD; from and to also work), pending, search, limit, offset. An unknown parameter is a 400. A row keeps status draft_created after its draft is posted: draft_entry_status says where that draft is now (draft: waiting for the owner; posted: booked; null when the row has no draft), so count a draft_created row with draft_entry_status posted as done. possible_duplicate_of is set (another row's id) when a row has the same bank account (any of its feeds), date, amount and description as another non-ignored row: ask the owner whether they were charged twice, and if it is a double import, ignore one with POST /api/bank-feed/transactions/{id}/ignore (and, when that row is draft_created, discard its draft_entry_id with DELETE /api/drafts/{id}). |
GET /api/bank-feed/transactions/{id} |
bankfeed:read |
One bank transaction with its full details (plaid_metadata), clarification and open_task as in the list (a needs_clarification row: the question process asked, ask_owner, reason and the next_actions for each answer; the open owner task about the row or its bank account), possible_duplicate_of (another row's id when one has the same bank account (any of its feeds), date, amount and description, as in the list), and linked_entry_reversed (true when the entry booking it was reversed with no posted replacement yet; note says how to finish the correction, naming any replacement draft already waiting under replacement_ids with next_actions to post it). When the statement also shows that reversal's money going back (a bounced check: deposit row, then return row), linked_entry_reversed is false and linked_entry_returned { entry_id, reversed_by, bank_row_for_reversal } with a note says the link is right and the row is done, as in /matches. |
POST /api/bank-feed/transactions/{id}/create-draft |
bankfeed:write |
Create a draft entry from an unmatched or needs_clarification bank row (Plaid or imported statement). The bank side is the feed's mapped account (409 bank_account_unmapped until POST /api/bank-feed/accounts/{feed_id}/map); the other side is account_id, else vendor_id's default_expense_account_id (offset_source vendor_default, money out), else a merchant rule's category (exact merchant name or merchant_pattern found in the description), else the account a posted entry used for an earlier row with the same description (offset_source prior_booking, prior_entry_id). With none of those it answers 400 account_required: ask the owner and resend with account_id, or send use_default_account: true for the uncategorized catch-all (the uncategorized_expense / uncategorized_income role, else an account named Uncategorized or Ask My Accountant, else 6000 for money out only when generic, never the 4000 sales account; with none it creates Uncategorized Expense 6990 / Uncategorized Income 4990, maps the role and returns catch_all_created { id, code, name, role }). Money in drafted to the catch-all adds warnings [{ code: unexplained_deposit, ask_owner, next_actions }]: unexplained deposits are often owner money, a loan or a transfer, so ask before it is posted. The response names offset_account { id, code, name } and has next_actions to review, recategorize and post the draft (on a row in a closed month, the post action carries confirmClosedPeriod: true and says so: ask the owner first). A row whose draft was deleted (draft_created / matched with no link left) can be drafted again. A row already recorded answers 409 transaction_already_handled (entry_id, entry_status); while its draft is still waiting, next_actions is PATCH /api/drafts/{id}, and when the request carried personal: true, account_id or account_code that PATCH carries the ready lines (Dr / Cr the bank and that account): resend it as is; when its entry is posted, next_actions starts with the ready POST /api/ledger/{id}/recategorize { account_id } to that account (the owner contribution or owner draws account for personal: true); when an invoice or bill payment (or a credit or refund) posted its entry, it names document and next_actions start with that document's own correction (void the payment when it was recorded in error: wrong customer, recorded twice), then recategorize only for a line on the wrong account. account_code (e.g. "3000") works instead of account_id. vendor_id links the draft to the vendor it paid (a contractor paid by Zelle, check or ACH): the 1099 summary counts it once posted, and the response names vendor { id, name, is_1099 }; money out to a 1099 vendor with no usable tax id (not from a credit card account) adds warnings [{ code: missing_tax_id, ask_owner, next_actions }]: ask the owner for the W-9. personal: true is owner money, booked to equity: money in to the owner contribution account (subtype owner_equity, not Opening Balance Equity), money out to owner_draws; 400 owner_account_required lists the candidates (at most 5; candidates_total, more_candidates and more_candidates_search when there are more) when none or several fit, ranked by this bank account's earlier use then a name like the bank account's, with recommended { id, code, name, why } and a create-draft next_action for it when one stands out (confirm it with the owner rather than asking them to pick). There is no customer_id: a client paying for work done without an invoice goes on their record as POST /api/invoices { customer_id, issue_date, lines } then POST /api/invoices/{id}/payments { amount, bank_transaction_id } (a draft invoice paid in full is marked paid without being sent). Unknown keys are a 400. Money in booked to the income account of stocked goods (a product's income account, or the sales account for products with none) that reads as a goods sale (a card processor payout, or an account only goods sell through) adds stock_sold { message, ask_owner, products [{ id, name, on_hand, average_cost, income_account }] } and a POST /api/inventory/movements { movement_type: "sale" } next_action per product: ask the owner which items sold and record them, or inventory and cost of goods sold stay wrong. Other money in to the sales account goods share with services (a client's ACH or check) gets only stock_sold { possible_only: true, message, product_count }, no ask_owner and no next_actions: don't ask about stock unless the owner says the deposit paid for goods. stock_sold leaves out goods first in stock after the bank date (they cannot be in this payout) and is omitted when no stocked product was on hand that day; products lists at most 5 (more_products counts the rest). When account_id (or the vendor default) picks the account for a named merchant, next_actions offers POST /api/merchant-rules (or PATCH the rule that books it elsewhere) as PATCH /api/drafts/{id} does, and same_text_rows [{ id, date, amount, status, draft_entry_id, booked_to }] lists other open rows with the very same bank text (not sorted yet, or drafted to another account) with a create-draft or PATCH next_action each, first, so one answer from the owner books them all; generic text (MOBILE DEPOSIT, CHECK 1043) gets neither. A row the feed flags owner_money ("TRANSFER FROM ... PERSONAL CHK", "OWNER CONTRIBUTION") drafted to an income account adds warnings [{ code: owner_money_to_income, message, ask_owner, bank_transaction_id, income_accounts, next_actions }] and offers no merchant rule (owner money is equity, never income); posting that draft answers 409 owner_money_income until resent with confirmOwnerMoneyIncome: true, only once the owner confirms a client paid it. Its mirror: a row that reads as the owner taking money ("TRANSFER TO ... PERSONAL CHK", "OWNER DRAW") drafted to an expense account (cost of goods sold included) adds warnings [{ code: owner_money_to_expense, message, ask_owner, bank_transaction_id, expense_accounts, next_actions }] and offers no merchant rule (a draw is equity, never an expense); posting it answers 409 owner_money_expense until resent with confirmOwnerMoneyExpense: true, only once the owner confirms it repaid them for a business cost they paid personally. Drafted to another of the business's own card or bank accounts (a card payment, a transfer), other_side_rows [{ id, feed_id, account_id, date, name, amount, direction, status, days_apart }] lists that account's feed rows of the same amount still waiting within 7 days (the card's PAYMENT THANK YOU), and next_actions starts with POST /api/bank-feed/transactions/{that row}/match { target_type: draft, target_id: this draft } for each: link it, never draft it again. Dated on the bank date; the owner reviews and posts it. |
POST /api/bank-feed/transactions/{id}/ignore |
bankfeed:write |
Mark a bank row ignored (a double import, a transfer booked elsewhere, not business). Returns transaction (clarification null: any stored question is answered), warnings, next_actions (GET the rows still waiting on the owner and the unmatched ones, when this row was needs_clarification) and, when the row already had its own draft, left_draft_id with next_actions DELETE /api/drafts/{id} so the duplicate is not posted (the draft is not deleted for you). A row linked to a posted entry (not reversed) answers 409 row_linked_to_posted_entry with next_actions: reverse the entry (POST /api/ledger/{id}/reverse) when the money should not be in the books, or resend with confirm: true when the row is a duplicate. A mistaken ignore is undone with POST /api/bank-feed/transactions/{id}/unignore (in next_actions). |
POST /api/bank-feed/transactions/{id}/match |
bankfeed:write |
Link a bank row to an existing capture, draft or posted entry (candidates: GET /api/bank-feed/transactions/{id}/matches). Returns transaction (the row, clarification null: a question process stored for a needs_clarification row is answered and dropped), warnings, left_draft_id and next_actions; when the row was needs_clarification, next_actions end with GET the rows still waiting on the owner and the unmatched ones. The entry is never edited, its date included. 409 amount_mismatch when the entry's bank-account line differs in amount or side (with difference and next_actions: for a two-line capture or draft, the PATCH /api/drafts/{id} setting it to the bank amount, then resend; confirm: true links as is and leaves the difference to record), 409 target_already_matched when another bank row of the same bank account links that entry (an entry touching two bank or card accounts, like a transfer or card payoff, is matched once on each), 409 transaction_already_matched when this row links elsewhere; confirm: true overrides the first and third after asking the owner. When the row had its own draft (from process or create-draft), that draft is left in place and returned as left_draft_id with next_actions DELETE /api/drafts/{id} to discard the duplicate. When the target is a draft, next_actions also carry GET /api/drafts/{id} and POST /api/drafts/{id}/post: it is linked but not in the books until the owner approves posting it. 409 target_reversed (reversed_by) when the target was undone by a posted reversal (a voided payment or refund) and no bank row shows that money going back; confirm: true overrides after the owner says this row is that entry. A transaction_already_matched on a row linked to such an entry adds current_entry_reversed_by and says so. When a void released the row from an entry that was cleared in a completed bank reconciliation and it is now linked to a new posted entry (the money re-recorded), the void and the new entry cancel there and are recorded as cleared in that reconciliation: reconciliation_cleared { message, reconciliations [{ reconciliation_id, chart_account_id, entry_line_ids }] }; the next reconciliation does not list them. The same applies to (and is returned in bank_transaction by) invoice payments, customer credits and vendor credit refunds recorded with bank_transaction_id. |
GET /api/bank-feed/transactions/{id}/matches |
bankfeed:read |
Entries a bank row could be matched to: drafts and posted entries not linked to another bank row of the same bank account (a transfer or card payoff already matched on the other account's feed is still offered here), whose line on the feed's bank account has this amount on this side (any line of the amount when the feed is unmapped). Each has counterparty (the customer or vendor of the invoice payment, bill payment or customer credit that posted it) and name_match (words of the bank text found in its description, counterparty or reference); counterparty_mismatch is true (with bank_text_names, the customer or vendor the bank text names instead) when the entry belongs to a different known customer or vendor than the one the bank text names: it is a different transaction, do not match it; those come last, then entries with more name_match, then nearest date (days_apart). POST /api/bank-feed/process never links a mismatched entry on its own. An empty name_match on every candidate means none names who the bank row is from: ask the owner before matching. Money in also lists invoices[]: open invoices (target_type invoice) the deposit may pay, found by the invoice number in the bank text (matched_by invoice_number) or by the customer it names when a balance covers the amount (matched_by customer_name; several invoices of one customer when the deposit equals their sum; when the deposit is more than their open balance, the invoice with excess > 0, an overpayment, and ask_owner: keep the extra as their credit or send it back?), each with balance_due, partial (amount below the balance), excess and next_action: POST /api/invoices/{id}/payments { amount, payment_method, payment_date, bank_transaction_id, excess_to_customer_credit: true when excess > 0 }, which records the payment (revenue, at payment) and links this row to it in one call. Record a customer payment that way, never as a draft that credits Accounts Receivable. Money out lists bills[] the same way: approved bills with a balance (target_type bill) found by the bill number in the bank text (matched_by bill_number) or by a vendor it names by a distinctive word (matched_by vendor_name) whose balance covers the amount, or, when the row is more than all of that vendor's open bills together, those bills with excess > 0 and ask_owner (the bills plus a deposit or rounding up kept with the vendor, or a separate purchase?) whose next_action sends the whole row with excess_to_vendor_credit: true (pays the bill, holds the extra as an open vendor credit, links the row; never draft the row to the expense the bill already booked), each with balance_due, partial, excess and next_action: POST /api/bills/{id}/payments { amount, payment_method, paid_at, bank_transaction_id }, which posts Dr Accounts Payable / Cr the row's bank account and links this row in one call. Pay a vendor's bill that way, never with a draft to the vendor's expense account (the approved bill already booked the expense). A bill issued after the row's date is never listed. When the one bill is found only by the vendor's name and the row pays part of it, or the text is a card swipe (POS, DEBIT CARD PURCHASE), ask_owner asks part of the bill or a separate purchase; a separate purchase is POST /api/bank-feed/transactions/{id}/create-draft { account_id, vendor_id }. When the bank text names no customer or vendor at all (CHECK 1203, MOBILE DEPOSIT) and nothing else fits, bills[] / invoices[] list the open bills and sent invoices whose balance equals the amount exactly (matched_by amount, at most three; a check's number goes in the bill payment's reference) with ask_owner: a guess from the amount alone, so ask the owner before calling the next_action. When no open bill fits money out (unmatched or needs_clarification) but a paid bill of the vendor the text names has this total, paid_bills[] lists it (id, bill_number, vendor_id, vendor_name, total, matched_by, paid_by_bank_rows: the bank rows linked to its payments) with ask_owner (paid the vendor twice, or the same payment imported twice?) and next_actions: POST /api/bills/{id}/payments for another open bill of that vendor it fits, ignore (a double import), and create-draft { account_id, vendor_id } to a prepaid / vendor deposits asset (paid twice: the vendor owes it back or holds it for a later bill; POST /api/accounts first when the chart has none), never to an expense. Entries cleared on a completed reconciliation of this bank account are not offered (the statement already showed them). A deposit that paid an invoice and left extra is one entry when recorded with POST /api/invoices/{id}/payments { amount, excess_to_customer_credit: true }. A deposit short of an invoice's balance whose customer holds open credit carries customer_credit { open_credit, applies, settles, next_action: { amount: applies, from_customer_credit: true } }, step 2 after the invoice payment (the client paid net of a retainer or overpayment), with ask_owner. Money in also lists vendor_credits[]: an open vendor credit (target_type vendor_credit; id, vendor_credit_number, vendor_id, vendor_name, remaining_credit, matched_by vendor_name) of the vendor the bank text names that covers the amount, with next_action POST /api/vendor-credits/{id}/refund { amount, payment_method, bank_transaction_id }; a credit whose number the bank text carries (VC-0015) comes first (matched_by vendor_credit_number). When the text names a vendor credit whose refund of this amount into this bank account is already recorded, possibly_recorded_as [{ id (the refund's entry), type, date, description, vendor_credit_id, vendor_credit_number, vendor_id, vendor_name, refund_id, refund_date, amount, entry_id, linked_row, feed_id }] names it, with reason, ask_owner (the same refund imported from two sources?) and next_actions: ignore this row first, a new vendor credit with its refund only if the owner says it was paid again. The refund call records the vendor's refund (Dr the bank / Cr Accounts Payable) and links this row; never create-draft it to the expense, the credit already took the expense back. Money in from a vendor the text names that says refund, return, rebate, overpayment or credit, with no vendor credit covering it, also lists vendor_receivables[]: prepaid, other asset or non-invoice receivable accounts holding at least the amount posted for that vendor (target_type vendor_receivable; account_id, code, name, balance, lines, vendor_id, vendor_name) with next_action create-draft { account_id, vendor_id } (Dr the bank / Cr that account): the vendor paying back what it owed, never income. Money out also lists customer_credits[]: an open customer credit (target_type customer_credit; id, customer_id, customer_name, open_credit, received_at, matched_by customer_name) of the customer the bank text names that covers the amount (a retainer, deposit or overpayment held in Customer Deposits), with next_action POST /api/customers/{customer_id}/credits/{id}/refund { amount, payment_method, bank_transaction_id }, which posts Dr Customer Deposits / Cr the bank and links this row; a refund of an unused retainer is that, never an invoice refund. Money in that pays no open invoice and says RETAINER, DEPOSIT or PREPAY, or names a customer with nothing open, carries customer_credit { customer_id, customer_name, word, reason } with ask_owner and next_actions: POST /api/customers/{id}/credits { amount, payment_method, bank_transaction_id } (Dr the bank / Cr Customer Deposits, links this row), or, when no customer is named, find or add the client first (POST /api/customers). A single shared trade word (bakery, cafe, plumbing) or TEST never names a customer. Money in on a credit card account never lists invoices or a customer credit (customers do not pay invoices into a card). An unlinked row with a draft or posted entry of a card payment or transfer from another own account (other_money_accounts) gets that entry's POST /api/bank-feed/transactions/{id}/match { target_type, target_id } first in next_actions, with ask_owner, ahead of any bill or invoice offered by amount alone; card money in that says PAYMENT THANK YOU (or PAYMENT RECEIVED, AUTOPAY) with nothing recorded yet points at the bank's waiting row of the same amount within 7 days (one naming this card's last4 first): create-draft on that row to this card, then match this row to the draft. Money out naming one of the business's own cards by its last4 (ONLINE PMT VISA CARD xx2410) lists no bills. An entry a posted reversal undid (a payment, refund or bill voided through its document, or POST /api/ledger/{id}/reverse) is left out unless a bank row of the account shows the money going back (a bounced check's return): reversed_entries_skipped [{ entry_id, reversed_by, reason }] names them, and the row is offered against the invoice or bill again; a kept one carries reversed_by and bank_row_for_reversal. |
POST /api/bank-feed/transactions/{id}/processor-payout |
bankfeed:write |
Book a card processor's payout (SQUARE INC DEPOSIT, STRIPE TRANSFER, SHOPIFY PAYMENTS, PAYPAL TRANSFER, VENMO CASHOUT, TOAST, CLOVER) at gross from the owner's processor statement: the bank shows sales net of fees, so a net income line understates sales by every fee, hides the fees and won't tie to the processor's 1099-K. Send gross (everything collected in this payout after refunds, sales tax included), fees, or both (gross - fees must equal the deposit, else 400 amounts_disagree), plus sales_tax when the statement shows tax collected: it is credited to sales tax payable (role sales_tax_payable; 400 sales_tax_account_required without one) and income gets gross - sales_tax, so a shop's card sales tax is owed to the state instead of booked as sales, and GET /api/reports/sales-tax counts those card sales as taxable. A payout mixing taxed goods and untaxed services (parts and repair labor) also sends non_taxable_sales from the statement: that part goes on its own income line marked taxable: false (non_taxable_income_account_id or _code, else the income account) and the report counts it as exempt (non_taxable_lines), not taxable; when sales_tax is well below the business's sales_tax_rate on the taxable sales, warnings carries tax_rate_below_sales_tax_rate asking for it. Replaces the lines of the row's draft (the one POST /api/bank-feed/process made) or, for an open row, creates the draft: Dr the bank (net) / Dr the fees account (fees) / Cr the income account (gross - sales_tax - non_taxable_sales) [/ Cr the non-taxable income account (non_taxable_sales, taxable: false)] / Cr sales tax payable (sales_tax). The income account defaults to the draft's current category (else a merchant rule's or earlier booking's; else 400 account_required, send income_account_id or income_account_code); the fee account to one named merchant / processing / card fees, else the bank fees account (fee_account_id or fee_account_code to choose; 400 fee_account_required when there is none). A row whose entry is already posted gets a correction pair instead: the reversal of the net entry (reusing one already drafted or posted) and a replacement draft at gross (replaces_entry_id), dated on the reversal's date (else the original's), so draft_entry_id is the replacement and correction { original_entry_id, reversal_id, reversal_status, reversal, transaction_date } plus a note say what the owner posts; the bank row moves to the replacement once both are posted, and calling again rewrites the same replacement (409 already_replaced once a replacement is posted). That is for an entry posted net (one income line); when the posted entry already books the payout split (a fee, sales tax or non-taxable line) and this call asks for different amounts or accounts, it is 409 posted_entry_already_split { entry_id, posted, requested, changes, ask_owner } and nothing is drafted (a retry with a mistaken number must not queue a correction that, say, moves collected sales tax into income); resend with confirm_correction: true only when the owner wants that change, and the response then carries posted and changes. Discard a correction pair with DELETE /api/drafts/{replacement}?delete_pair=true (both drafts), never one half. Returns draft_entry_id, draft (created | updated), correction and note for a posted entry, net, gross, fees, sales_tax, income (gross - sales_tax), taxable_sales, non_taxable_sales, lines (taxable: false on the untaxed line) and next_actions (review, post, and the merchant rule for the income account when none books it there); without sales_tax, note asks for it when the business tracks sales tax or sells goods. When the income account is one stocked goods sell through (a tracked product's income account, or the sales account for products with none), stock_sold { message, ask_owner, products [{ id, name, sku, on_hand, average_cost, warehouse_id, income_account }], already_sold [{ movement_id, product_id, product_name, quantity, entry_id }] } says the payout books only the sales, and next_actions carries POST /api/inventory/movements { movement_type: "sale", product_id, quantity, date (the payout date), reference_type: "bank_transaction", reference_id (this row) } per product: ask the owner which items sold (the processor's item sales report) and record each, or stock never goes down and cost of goods sold stays 0. stock_sold leaves out goods first in stock after the bank date (they cannot be in this payout) and is omitted when no stocked product was on hand that day; products lists at most 5 (more_products counts the rest). For a service business (business_type service), or a payout sent with no sales_tax, stock_sold is { possible_only: true, message, ask_owner: "Did this payout include any products you sell, or was it all services?", product_count } with no inventory next_actions: ask once, and record sales only if the owner says products were in it. When sales are already recorded on this row (already_sold), the message says so and a product already sold gets only the confirm_duplicate: true variant (for extra units the owner names), so a retry does not ask again. Money out is 400 not_money_in: a chargeback or refund is create-draft to the income account (POST /api/bank-feed/process offers it). |
POST /api/bank-feed/transactions/{id}/resolve |
bankfeed:write |
Resolve an unmatched or needs_clarification bank row: business or personal creates a draft (account_id picks the category), note returns captureText for QuickCapture. 409 bank_account_unmapped until the feed is mapped. |
POST /api/bank-feed/transactions/{id}/unignore |
bankfeed:write |
Undo a mistaken ignore. The row returns to draft_created (it kept its own draft or posted entry), matched (it kept a link to a posted entry or capture) or unmatched (no link, or the linked entry is gone; next_actions then run POST /api/bank-feed/process again). Returns transaction, warnings, next_actions. 409 transaction_not_ignored when the row is not ignored. |
/api/bank-reconciliation 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/bank-reconciliation |
reconciliation:read |
Reconciliation sessions, newest statement first. Filter with ?chart_account_id and ?status (in_progress | completed | cancelled); page with ?limit and ?offset (next_offset is set while more remain). An unknown parameter is a 400. |
POST /api/bank-reconciliation |
reconciliation:write |
Start reconciling a bank, cash or credit card account against a statement (400 future_statement_date when statement_ending_date is after the business's today; 400 already_reconciled_through when it is on or before the last completed reconciliation's statement date, with reconciliation_id and next_actions to reopen it, PATCH its statement_ending_balance and complete it again, the way to extend a statement with a later export of the same day). Beginning balance defaults to the last completed reconciliation's ending balance, or on the first one to the posted ledger balance the day before the statement starts. On the first one, without statement_start_date the statement starts where the imported statement ending on statement_ending_date starts (its printed start, or the first of its first row's month) and, without beginning_balance, begins at that statement's beginning balance (statement_start_from and beginning_balance_from: imported_statement; the books are checked against it as beginning_balance_gap); with no such import, the first of the ending month. A first reconciliation started later than, or at another beginning than, that imported statement still starts but returns imported_statement_check { code: imported_statement_start_differs, imported_statement { period_start, beginning_balance }, session, message, next_actions } (cancel and restart from the statement's start) in warning: the rows before the session's start would sit inside its beginning balance, never checked. The session's beginning_balance_as_of is the date that balance is as of (the prior statement's ending date); candidate_lines_after is only the floor: lines dated on or before it are never candidates. A beginning_balance that differs from the books (the ledger balance on beginning_balance_as_of on the first reconciliation, the prior statement's ending after that) still starts the session but returns beginning_balance_gap { basis, as_of, books, statement, difference, ledger_lines_through_as_of, message, ask_owner, next_actions }; when the books have nothing on the account before the statement, its next_action drafts the missing opening balance against Opening Balance Equity for the owner to confirm; when that opening is already drafted, pending_opening_draft { entry_id, date, amount } and the next_action posts it instead; when the books are higher because two posted openings of the same date and amount repeat each other, duplicate_openings [{ entry_id, duplicate_of, date, amount }] and next_actions reverse the duplicate with same_date: true; on a credit card, a beginning_balance that is minus the books' amount owed (-340.00 for 340.00 owed) carries sign_flipped: true and next_actions cancel and restart at the amount owed (the ledger is not wrong, draft no correction). Complete refuses the session until the gap is gone. On a credit card statement_ending_balance is the amount owed as a positive number; a negative one (the bank feed import's statement.ending_balance sign) still starts the session but returns sign_check { code: card_balance_sign, amount_owed, books_owed, ties_with_books, message } in warning and a PATCH { statement_ending_balance: amount_owed } first in next_actions; GET then names a sign-flipped balance (difference twice the balance) as exact match statement_balance_sign_flipped. |
GET /api/bank-reconciliation/{id} |
reconciliation:read |
A reconciliation session with its candidate lines (posted lines after candidate_lines_after through the statement date that no completed reconciliation of the account cleared; a line dated inside an earlier statement is one only when that statement left it uncleared, and a recategorize pair whose original an earlier statement cleared is cleared there with it, cleared_source correction_pair), cleared balance and difference. Each line's feed_matched is whether a bank feed row links to it now; cleared_source is how it was recorded cleared at the time, so after a correction the original keeps feed_match while its bank row moved to the replacement (replaced_by_entry_id), and the replacement (replaces_entry_id) shows feed_matched true with correction_pair; cleared_note says so on those lines. While in progress, difference_explanation lists what could explain a difference on this account through the statement date: uncleared_lines { count, total, equals_difference, entry_line_ids } (every uncleared posted line together, entry + reversal pairs left out), self_cancelling (an uncleared entry and its uncleared reversal: they net to zero and no statement shows them; each has a next_action clearing both, which leaves the balance unchanged and stops them recurring next month), unposted_drafts (draft entries, with the bank_transaction_id they came from) and feed_rows_in_review (bank feed rows unmatched or needs_clarification), each with effect (what it adds to the cleared balance once posted and cleared), plus exact_matches when one item (all uncleared lines together, all unposted drafts together, one draft, feed row or uncleared line) equals the difference, each with a next_action; those come first in next_actions. A cleared line can explain it too: cleared_not_on_statement [{ entry_line_id, entry_id, date, description, signed_amount, feed_matched, not_on_imported_statement, equals_difference }] lists cleared lines whose amount is the difference with the sign flipped and lines whose bank row the statement imported for this date does not show (on the account before the file came in, no row of the file with its amount); exact_matches kind cleared_line (or cleared_lines_not_on_imported_statement for several together) carries PATCH .../items { entry_line_ids, cleared: false }, first when the imported statement does not show it. Uncheck it once the owner confirms it is not on the statement: never draft an entry or change statement_ending_balance to cover it. imported_statement { import_id, filename, period_end, ending_balance, statement_ending_balance, entered_minus_imported (given less imported), difference_with_imported (the session's difference with the imported balance: 0 means it ties) } is set when a statement imported into this account ends on the statement date at another balance (854.74 given, the import ends at 845.74), and the message says so; on a credit card it compares the import's amount_owed (its ending_balance, negative when owed, as a positive number), so the amount owed the sign_check PATCH gives is not second-guessed; when the imported balance ties the session, an exact match offers PATCH /api/bank-reconciliation/{id} { statement_ending_balance } first (confirm with the owner). A difference divisible by 9 is named as two swapped digits. On a reopened session, added_after_completion { reopened_at, count, total, equals_difference, entry_line_ids } lists the cleared lines that were not in it when it was completed; when they make the difference, the exact match offers PATCH /api/bank-reconciliation/{id} { statement_ending_balance } (the statement shows them) or unclearing them (alternative_action, they belong to a later statement). Clear lines only after the owner confirms they are on the statement. Never post a draft without the owner's go-ahead. beginning_balance_gap (in progress or completed) is set when beginning_balance is not what the books say on beginning_balance_as_of (the ledger on the first reconciliation, the prior statement's ending after that): the cleared balance can tie while the ledger stays off the statement, so complete refuses it; its next_actions draft the missing opening balance (when the books have nothing before the statement) or find the missing entry. |
PATCH /api/bank-reconciliation/{id} |
reconciliation:write |
Correct an in-progress reconciliation's statement_ending_balance and/or statement_ending_date (for example after reopening a completed one whose statement now includes lines posted after it was completed). Cleared lines are kept; the change is audit-logged with before and after. Returns cleared_balance, difference and reconciled. When a statement imported into the account ends on the statement date at another balance, adds warnings [{ code: imported_statement_differs, message }], imported_statement { ending_balance, statement_ending_balance, entered_minus_imported, difference_with_imported (the difference with the imported balance; 0 means it ties) }, ask_owner, and next_actions led by the PATCH to the imported balance when that ties. 409 not_in_progress for a completed (reopen it first) or cancelled session; 400 future_statement_date for a date after the business's today; 400 invalid_statement_ending_date when the date is not after beginning_balance_as_of or not before the account's next completed statement. |
POST /api/bank-reconciliation/{id}/cancel |
reconciliation:write |
Cancel an in-progress reconciliation (kept as status cancelled, nothing in the ledger changes) so the account can start a new one. A reopened session that was once completed answers 409 was_completed unless confirm_was_completed: true, since the account loses that completed reconciliation; to correct its statement figures use PATCH /api/bank-reconciliation/{id}. |
POST /api/bank-reconciliation/{id}/complete |
reconciliation:write |
Complete an in-progress reconciliation (409 already_completed on a completed one, with status, completed_at and the reopen call; 409 not_in_progress on a cancelled one) once the difference is 0 and the beginning balance matches the books (409 beginning_balance_mismatch with beginning_balance_gap, ask_owner and next_actions when it does not: on a first reconciliation the ledger balance on beginning_balance_as_of must equal beginning_balance, so record the missing opening balance first; 422 difference_not_zero otherwise, with difference, cleared_balance and difference_explanation: unposted drafts and bank feed rows in review on the account through the statement date, and exact_matches with next_actions when one equals the difference, including a cleared line to uncheck). 409 uncleared_opening { openings [{ entry_id, date, amount, entry_line_ids }], next_actions } when a posted opening balance entry on the account (a line on Opening Balance Equity) is left uncleared: an opening is never an outstanding deposit, so it is a second opening and the books would stay above the bank by it; next_actions draft its reversal on its own date (POST /api/ledger/{id}/reverse { same_date: true }), then complete again; resend with confirmUnclearedOpening: true only if the owner confirms the money is still on its way. An uncleared entry and its uncleared reversal on the account (net 0, e.g. an opening and its reversal) are cleared together on completion, balance unchanged, and listed in cleared_self_cancelling [{ entry_id, reversal_entry_id, amount, entry_line_ids }] so they do not recur as outstanding items. On success, warnings [{ code: feed_matched_left_uncleared, message, entry_line_ids }] names lines matched to a bank feed row that were left uncleared (they should be on a later statement; not a block). Cleared entries get reconciliation_id and reconciled_at and are read-only until the session is reopened; the separate locked field (opening balances) is unchanged. Clearing is per line: a transfer or card payment cleared here is still a candidate on the other account's reconciliation. |
PATCH /api/bank-reconciliation/{id}/items |
reconciliation:write |
Mark one or many candidate lines of an in-progress reconciliation cleared or uncleared ({ entry_line_ids: [...], cleared: true }); any non-candidate id refuses the call. Returns the items plus totals { cleared_balance, difference, reconciled, cleared_count, uncleared_count } after the change, so no GET of the session is needed to see whether it ties. When the difference is not 0 and one item equals it (a line just uncleared, a statement balance typo), exact_matches and next_actions carry the same calls as complete's 422 difference_not_zero; next_actions is the complete call when it ties |
POST /api/bank-reconciliation/{id}/reopen |
reconciliation:write |
Reopen a completed reconciliation (reason required, audit-logged): its entries unlock and it is in progress again. Then clear or unclear lines, correct the statement figures with PATCH /api/bank-reconciliation/{id} if the statement differs, and complete it again. Cancel is the wrong way out: it drops the account's completed reconciliation. |
/api/barcode 1
| Operation | Scope | What it does |
|---|---|---|
POST /api/barcode/resolve |
inventory:read |
Resolve a scanned barcode or typed code to what it identifies in this business: a product (by barcode, then SKU), a warehouse bin location, a purchase order or a sales order. Returns { resolution }. |
/api/bills 13
| Operation | Scope | What it does |
|---|---|---|
GET /api/bills |
ledger:read |
Bills (what the business owes vendors), newest first, with the vendor. Filters: status (draft | pending_approval | open | partial | paid | cancelled; open excludes partial, a bill paid in part or with vendor credit applied), payment_status=unpaid (everything still owed: open and partial, what A/P aging adds up; use it for what bills are still owed), vendor_id (404 vendor_not_found when it is not a vendor of this business), location_id, search (bill number, vendor name, notes), bill_number (exact, ignoring case), from/to (issue_date), due_before (due_date on or before), limit/offset for paging. Before entering a bill, check it isn't already there: ?vendor_id=&bill_number= or ?search=. Returns total_count (all matches) and has_more; filters echoes what was applied, and any other query parameter is a 400. |
POST /api/bills |
ledger:write |
Create a draft A/P bill. A line names its account by account_id or account_code (400 unknown_account_code, account_mismatch). Lines without one take the vendor's default_expense_account_id; line_accounts in the response shows the account each line will debit at approve. A line left unresolved (no account and no vendor default) comes back in unresolved_lines with next_actions, and approve refuses it with 409 expense_account_required; otherwise next_actions is the approve call. When the vendor holds open vendor credit (a prepayment, deposit or return), vendor_open_credit { total, credits [{ id, number, credit_date, remaining }] }, open_credit_note and ask_owner say so, and next_actions add POST /api/vendor-credits/{id}/apply { bill_id, amount } after the approve (credit goes only on an approved bill) and the payment of what is left. Unit prices cannot be negative. due_date defaults to issue_date plus terms_days (net days), else the vendor's default_terms_days; with neither the bill is due on receipt (due_date = issue_date) and the response carries due_date_note so you can check the vendor's terms. Any key not listed in the body is a 400 with a did-you-mean hint (due_days: did you mean terms_days?). An issue_date in a closed month adds warnings [{ code: closed_period, message, ask_owner, next_actions }]: approve will need confirmClosedPeriod: true, so ask the owner before approving. For a bill still unpaid at the switch date when moving from QuickBooks, Xero or a spreadsheet, pass opening_balance: true (opening_balance_account_id defaults to accounts payable): it opens with no accrual, so the expense is not booked twice, and its payments clear the opening A/P. Refuses with 409 code possible_duplicate (listing the existing bills, changing nothing) when the vendor already has a bill with the same bill number, or the same issue date and total; resend with confirm_duplicate: true only if it really is a different bill. With purchase_order_id, lines carrying purchase_order_line_id are matched against the order: billing more than was received (goods lines) or ordered, counting the order's other bills, answers 409 over_billed with over_billed lines and ask_owner; resend with confirm_over_billing: true only if the owner confirms. A 1099 vendor with no usable tax id (blank or a placeholder) adds warnings [{ code: missing_tax_id, ask_owner, next_actions, open_task? }]: ask for the W-9 before paying. A 1099 vendor with no usable tax id adds warnings [{ code: missing_tax_id | invalid_tax_id, message, ask_owner, next_actions, open_task?, backup_withholding? }]; backup_withholding { year, paid_this_year, threshold, rate } is set once the vendor has been paid the year's 1099 threshold, and the message and ask_owner then lead with possible 24% backup withholding on further payments. When the response has two questions for the owner, ask_owner is the first and asks[] lists both in order: ask one at a time. |
DELETE /api/bills/{id} |
ledger:write |
Delete a draft bill (lines cascade via FK). |
GET /api/bills/{id} |
ledger:read |
A bill with its vendor, lines, payments and vendor credit applications; balance_due nets payments and applied credit. While it is still to be paid (draft, pending_approval, open, partial) and the vendor holds open vendor credit (a prepayment, deposit or return), vendor_open_credit { total, credits [{ id, number, credit_date, remaining }] }, open_credit_note, ask_owner and next_actions (approve first on a draft, then POST /api/vendor-credits/{id}/apply { bill_id, amount }, then the payment of what is left) say how to take it off before paying. |
PATCH /api/bills/{id} |
ledger:write |
Edit a draft bill (400 once it is approved). Sending terms_days or issue_date without due_date sets due_date to issue_date plus the terms (sent, else the bill's own) and answers due_date_note. lines replaces every line. Any key not listed in the body is a 400 with a did-you-mean hint (due_days: did you mean terms_days?). |
POST /api/bills/{id}/approve |
ledger:write |
Approve a draft or pending-approval bill. At or above the bill approval threshold (and not matched to a purchase order) a draft moves to pending_approval; otherwise the A/P accrual posts on the bill's issue_date and the bill opens, with next_actions: pay it (POST /api/bills/{id}/payments) or wait for the bank or card row, which POST /api/bank-feed/process links. When the vendor holds open vendor credit (a prepayment, deposit or return), vendor_open_credit, open_credit_note and ask_owner say so and next_actions put POST /api/vendor-credits/{id}/apply { bill_id, amount } first, then the payment of balance_due less the credit. An issue_date in a closed month answers 409 closed_period until resent with confirmClosedPeriod: true. Lines debit their account_id, else the vendor's default_expense_account_id; when neither is set it answers 409 expense_account_required with next_actions and books nothing. A goods line on a purchase order clears Goods Received Not Invoiced at the cost its receipt booked; a bill price above or below that posts the difference to purchase price variance (role purchase_price_variance, else Cost of Goods Sold) and the response carries price_variance. A 1099 vendor with no usable tax id adds warnings [{ code: missing_tax_id, ask_owner, next_actions, open_task? }]: ask for the W-9 before the payment. A 1099 vendor with no usable tax id adds warnings [{ code: missing_tax_id | invalid_tax_id, message, ask_owner, next_actions, open_task?, backup_withholding? }]; backup_withholding { year, paid_this_year, threshold, rate } is set once the vendor has been paid the year's 1099 threshold, and the message and ask_owner then lead with possible 24% backup withholding on further payments. When the response has two questions for the owner, ask_owner is the first and asks[] lists both in order: ask one at a time. |
GET /api/bills/{id}/payments |
ledger:read |
List the payments recorded against a bill. |
POST /api/bills/{id}/payments |
ledger:write |
Pay an approved bill, in full or in part: { amount, payment_method?, reference?, paid_at?, from_account_id?, confirmClosedPeriod?, confirmReconciledPeriod? }. Posts Dr Accounts Payable / Cr from_account_id (default the cash role (it must be named when the business has more than one cash or bank account: 400 deposit_account_required lists at most 5 (accounts_total and more_accounts, GET /api/accounts?subtype=bank, for the rest), each with why: the account this bill's earlier payments came out of, then the vendor's last payment, then the account most recent bill payments used, then the cash role account, with a next_action per one; no_likely_account: true when none applies; a paid or unapproved bill, or an amount over balance_due, gets its 409 first and is never asked for the account)) on paid_at (default today, never in the future: 400 invalid_paid_at with ask_owner and a resend dated today; with bank_transaction_id the row's date, accepted even a day past today as the import allows). The invoice payment names payment_date and deposit_account_id (and account_id, payment_account_id) are accepted as aliases; payment_method takes the same values as invoice payments (cash, check, ach, bank_transfer, wire, card, zelle, venmo, cashapp, apple_pay, other; card marks a 1099-K payment). Errors carry a code: 409 not_approved (POST /api/bills/{id}/approve first), 409 already_paid (paid_by_bank_rows: the bank rows linked to its payments, and ask_owner: paid the vendor twice, or the same payment again? With bank_transaction_id, next_actions carry that row: another open bill of the vendor it fits, ignore it as a double import, or create-draft { account_id, vendor_id } to a prepaid / vendor deposits asset, with POST /api/accounts first when the chart has none; never an expense, the bill already booked it), 409 overpayment (amount over balance_due: balance_due, excess, ask_owner and next_actions to resend with excess_to_vendor_credit: true or to pay only the balance; with bank_transaction_id only the first, since the amount must equal the row). excess_to_vendor_credit: true pays the bill its balance and holds the extra as an open vendor credit in the same posted entry (Dr Accounts Payable the whole amount / Cr from_account_id the whole amount, so a bank row of the whole amount matches it): the response adds paid_total, excess and vendor_credit { id, vendor_credit_number, amount, credit_date } with next_actions to POST /api/vendor-credits/{id}/apply it to the vendor's next bill (it shows in that bill's vendor_open_credit and nets in A/P aging) or /refund it; when money in from that vendor of at most the extra is already waiting on the bank feed (often on the same statement), refund_rows_waiting lists those rows and the credit's first next_action is the refund { amount, payment_method, bank_transaction_id } that records it on the row; voiding the payment voids that credit while it is unused. Never draft a debit to Accounts Payable by hand for the extra. Also 409 closed_period for a paid_at in a closed month until resent with confirmClosedPeriod: true (on an opening-balance bill dated before it was carried over it adds before_books_start, books_start_date and ask_owner), 409 payment_before_bill when paid_at is before the bill's issue_date (ask_owner: paid ahead, or a separate purchase? Resend with confirmPaymentBeforeBill: true once the owner says it was paid ahead), 409 reconciled_period when paid_at is on or before the statement date of the paying account's last completed bank reconciliation (reconciliations and ask_owner: is it on that statement, or hasn't it cleared yet? Without a bank row next_actions[0] is the same request with confirmReconciledPeriod: true for one not cleared yet (it stays outstanding for the next reconciliation), then reopen only for one on that statement; a check keeps its paid_at, never redated to the clearing date; posted_to_reconciled_period lists them after). The response echoes transaction_date and from_account. bank_transaction_id names the bank-feed row (money out) this payment is (POST /api/bank-feed/process outcome bill_payment, or bills[] on GET /api/bank-feed/transactions/{id}/matches): from_account_id defaults to that row's bank account (400 from_account_mismatch for another), paid_at to its date (a paid_at other than the row's date is 409 payment_date_differs_from_bank_row with bank_row_date, days_apart, ask_owner and a next_action resending on the row's date, unless confirm_date_differs_from_bank_row: true after the owner says so; a check may keep an earlier paid_at, the day it was written), amount must equal the row (400 bank_amount_mismatch), 409 transaction_already_matched when the row is already in the books (a row still linked to the entry of a voided payment or refund, with no bank row showing that money going back, is taken over instead); after posting, the row is linked to the payment entry (bank_transaction { id, status matched, matched_entry_id }). Paying a 1099 vendor with no usable tax id (not by card) adds warnings [{ code: missing_tax_id, ask_owner, next_actions, open_task? }] worded after the fact (the payment is recorded; the W-9 is still needed for the 1099). POST /api/bills and /approve carry the same warning before any payment. A payment that leaves its bank or cash account below zero on paid_at adds warnings [{ code: account_overdrawn, account { id, code, name }, balance_after, as_of, no_prior_activity, message, ask_owner, next_actions }] (the payment is recorded): ask the owner whether the account's opening balance is missing (GET /api/setup/status opening_check) or it was paid from another account (void it { reason } and record it again with that from_account_id). |
POST /api/bills/{id}/payments/{paymentId}/void |
ledger:write |
Undo a bill payment recorded in error (not paid yet, wrong bill, wrong date, recorded twice): { reason, confirmClosedPeriod?, confirmReconciledPeriod? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. Posts the mirror of the payment's entry on the payment's own date (void_entry_id, a posted REVERSAL linked to reversed_entry_id), lowers the bill's amount_paid so it is open or partial again in A/P aging, sends the bank-feed row matched to the payment back to unmatched (bank_rows_released), and keeps the payment with voided_at and void_reason; GET /api/bills/{id}/payments shows it next to its offset row (negative amount, voids_payment_id), and the 1099 summary drops both. Re-record a payment made on another day with POST /api/bills/{id}/payments. 400 invalid_id (a non-UUID id or paymentId; paymentId answers with the call that lists the real ids), 404 payment_not_found, 409 already_voided, is_void_record (the offset row), bill_cancelled, reversal_draft_pending (delete the draft from POST /api/ledger/{id}/reverse first), closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true. A payment sent with excess_to_vendor_credit also voids the vendor credit holding its extra (vendor_credit_voided { id, vendor_credit_number, amount }), since the mirror entry reverses that part too; 409 overpayment_credit_used when that credit was already applied or refunded (next_actions: POST /api/vendor-credits/{id}/applications/{appId}/void for each application and .../refunds/{refundId}/void for each refund, then void the payment again). A vendor that really was paid and sent the money back is a vendor credit with refund, not a void. |
POST /api/bills/{id}/reject |
ledger:write |
Reject a bill awaiting approval: it returns to draft so the operator can edit and resubmit, with the rejector, time, and optional reason recorded. No journal is booked either way (the accrual is only booked on approve). Approve is the sibling action (../approve). |
POST /api/bills/{id}/void |
ledger:write |
Void (cancel) a bill billed in error or cancelled by the vendor: { reason, confirmClosedPeriod? }; reason is required (400 without it, ask the owner why), stored as metadata.void_reason and in the void entry's description; an unknown key is a 400. A draft or pending-approval bill just becomes cancelled. An approved bill (open, nothing paid or credited) gets its A/P accrual reversed by a posted entry dated on the accrual's date (void_entry_id, reversed_entry_id), so the expense and Accounts Payable read as if it was never entered (the entry carries the vendor like the accrual), and becomes status 'cancelled' with cancelled_at: it keeps its number and leaves A/P aging, GET /api/bills?status=open and the bank-feed bill matches. 409 has_payments when something was paid or credited on it (amount_paid, credit_applied, balance_due, next_actions: POST /api/vendor-credits { apply_to_bill_id } for the unpaid balance, and a vendor credit with refund { bank_transaction_id } for money the vendor sent back); 409 already_cancelled (balance_due 0, void_reason); 409 reversal_draft_pending when its accrual already has a reversal draft (delete it first); 409 closed_period when the accrual's month is closed, until resent with confirmClosedPeriod: true. Never reverse the bill's entry with POST /api/ledger/{id}/reverse: the bill would stay open in A/P aging. |
POST /api/bills/import-open |
ledger:write |
Import the bills still unpaid at the switch date when migrating (switching) from QuickBooks, Xero or a spreadsheet: content (QuickBooks A/P Aging Detail or Unpaid Bills report CSV; bill payments, vendor credits and total lines are skipped, but a negative Bill Payment / Vendor Credit / Journal row with a vendor is money paid ahead or a credit not used yet and comes back in unapplied_credits { total, rows, by_party, next_actions: POST /api/vendor-credits per vendor }: tie_out expects A/P at the gross open bills with that money in a prepaid asset, and a posted A/P holding only the net answers open_items_net_of_credits with the correction draft in next_actions; also Xero's Aged Payables Detail, where the Due column is the open amount and Total - Paid - Credited must equal it, else the row is invalid; skipped_lines lists the skipped lines and file_total compares the file's Total line with the rows (matches: the file adds up; import_total is what the import creates, and duplicate_total / duplicate_rows the rows repeated in the file, counted in rows_total but created once); a skipped bill payment to an is_1099 vendor is old-system 1099 money: skipped_1099_payments lists it, ask_owner asks for the old system's total through the day before the switch and next_actions has the POST /api/vendors/{id}/prior-1099-payments call) or rows [{ number, vendor | vendor_id, date, due_date, open_amount, original_total, account_id | account_code }]. The export's original total (QuickBooks Amount or Xero Total beside the open amount, or rows[].original_total) shows on every preview row as original_total; a row whose open amount is more than its own total is invalid (columns crossed or a bad export), never imported. It is kept on the bill as metadata.original_total and metadata.settled_before_switch (ap-aging records originalTotal / settledBeforeSwitch) when part was paid before the switch; the bill still opens at the open amount. account_id / account_code is the row's expense account (or expense_account_id for every row; else the vendor's default_expense_account_id, else the catch-all): it never posts, but when the old books were cash basis it is what the cash-basis reports count the payment as; rows[].category shows what each row got, category_note counts the ones without, and POST /api/bills/import-open/categories sets it after the import. An Account / Category column in the file (a chart code like 6152, "6152 Job Materials" or the account name) sets the row's category when it names an expense account; account_column { header, categorized, ignored [{ row, value, reason }] } says which rows it set and why the others were left to the fallback (a column naming the A/P or A/R account itself is ignored). column_mapping and unused_columns show which headers were read and which were dropped. Each row becomes an opening_balance bill for its open amount (open, no accrual, payments clear the opening payable), keeping its number. Vendors are matched by name (import them first with POST /api/vendors/import); a bill the vendor already has under that number is skipped (numbers compare ignoring case, spaces and punctuation, so INV-0050, inv 0050 and INV0050 are one; with no number, same party, date and amount). Send preview: true first: rows say would_create | exists | exists_but_paid | duplicate_in_file | party_not_found | not_open | invalid (exists_but_paid: the export calls it open but it is paid here, so the export is stale or a payment went to the wrong document; paid_here lists them and ask_owner asks which is right; never import it again); numbers are matched before a row is checked, so a row whose vendor and number are already here is exists (or exists_but_paid) even when it is malformed (export_error says what is wrong with the export's line, e.g. Total - Paid - Credited not equal to Due) and never counts as left out; an exists or exists_but_paid row on an opening_balance bill that carried over at another amount (the bill's total, payments here aside) or other dates than the export shows carries differences [{ field: open_amount | issue_date | due_date | party, export, books }], matched_differences lists them, ask_owner asks which is right and next_actions GET the bill with how to correct it; an amount difference makes tie_out matched_items_differ (ties false, difference = export - books) while the new bills are still created; when the rows read tie but rows are party_not_found or invalid, tie_out is rows_not_read (ties false, rows_not_read { count, amount, file_total (the file's total), already_in { count, amount } (rows of the file already in the books) }, ask_owner whether the left-out ones are still owed: if so the opening A/P needs their amount too), never Ties, while the rows read are still created; tie_out compares the total with the opening payable account (posted credit balance only, less open bills already in it; draft entries are reported in draft_balance / draft_entry_ids, not counted). When it ties only by counting a draft opening entry the commit answers 409 opening_balance_not_posted (have the owner post it, then resend); when it doesn't tie, 409 open_items_do_not_tie; creates nothing, and ask_owner says what to ask; resend with confirm_difference: true once the owner confirms. A would_create row for an is_1099 vendor with no usable tax id adds that vendor's W-9 warning to w9_warnings (as bill create does); once the vendor's payments this year (old-system prior-1099 amounts included) are at or over the 1099 threshold it carries backup_withholding and its question joins ask_owner. Without opening_balance_account_id the standard Accounts Payable account is used; account_choice names it and ties out the other A/P accounts (alternatives: any that tie, the standard one when another was picked and, in a preview that does not tie, the 3 closest by difference; alternatives_total counts them all), and when another one ties, next_actions resends with its id. A row dated after the switch (after the export's As of date, else the posted opening entry's date; the response's as_of) is status after_switch: new activity, not imported and not counted in tie_out; after_switch { switch_cutoff, rows, total, ask_owner, next_actions } carries the POST /api/bills body that enters each as a normal bill. include_after_switch: true imports them as opening items only when the owner says the old balance includes them. |
POST /api/bills/import-open/categories |
ledger:write |
Set the expense account (category) of bills carried over at the switch date (opening_balance bills from POST /api/bills/import-open): items [{ id, account_id | account_code }]. Nothing posted changes, since their payments clear the opening payable; when the old books were cash basis (prior_books_basis cash_basis), the cash-basis reports count their payments in this account instead of the catch-all expense account. A report's basis.opening_items_without_category lists the ones without one. Returns updated[] and failed[] (not_found, not_opening_item, invalid_category_account). |
/api/budgets 7
| Operation | Scope | What it does |
|---|---|---|
GET /api/budgets |
budgets:read |
List the current business's budgets, newest fiscal year first. |
POST /api/budgets |
budgets:write |
Create a budget header (name + fiscal year range). Lines are edited separately via /api/budgets/:id/lines. business_id and created_by are stamped server-side. |
DELETE /api/budgets/{id} |
budgets:write |
Remove a budget and its lines (lines cascade via FK). |
GET /api/budgets/{id} |
budgets:read |
A single budget header plus all of its lines (getWithLines). |
PATCH /api/budgets/{id} |
budgets:write |
Edit header fields (name, fiscal year range). Lines are edited via the /lines sub-route. |
GET /api/budgets/{id}/lines |
budgets:read |
All planned lines for a budget (account_id, period_month, amount), ordered by account then month. |
PUT /api/budgets/{id}/lines |
budgets:write |
Set planned amounts: each line is (account_id, period_month, amount). period_month may be any day of the month (stored as the 1st) and must fall inside the budget's fiscal year (400 period_outside_budget otherwise). Re-sending a cell overwrites it; send amount 0 to clear one. |
/api/business-locations 4
| Operation | Scope | What it does |
|---|---|---|
GET /api/business-locations |
locations:read |
List the business's locations (stores, offices, job sites, branches). A location id tags drafts, bills and invoices (location_id) for per-location reporting: GET /api/reports/profit-and-loss?location_id=. Inventory stock places are /api/catalog/warehouses. Optional ?active=true|false and ?location_type= filters. Returns { locations }. |
POST /api/business-locations |
locations:write |
Create a business location for per-location reporting (name, optional type and address); use its id as location_id on drafts, bills and invoices. Returns 201 { location }. |
DELETE /api/business-locations/{id} |
locations:write |
Archive a business location (is_active -> false; past entries keep their location_id). Returns { location }; 404 if it is not in this business. |
PATCH /api/business-locations/{id} |
locations:write |
Update a business location's name, type, address, or is_active flag (at least one field). Returns { location }; 404 if it is not in this business. |
/api/captures 7
| Operation | Scope | What it does |
|---|---|---|
POST /api/captures |
quickcapture:write |
Record something that happened in plain words (note, e.g. "$142.18 State Farm insurance from checking on 2026-08-28"); the AI picks the accounts. Dated transaction_date, else the date in the note, else today. Confident results come back autoProcessed:true with draftId (a draft, already with its lines: review it with GET /api/drafts?status=draft, post with POST /api/drafts/{id}/post when the owner approves); otherwise it stays a capture (GET /api/drafts?status=capture) to finish with POST /api/captures/{id}/approve. Send the receipt image or PDF in the same call as receipt { filename, content_type, data_base64 } (10 MB max; it is read before the AI classifies, so the note can be short or left out), or attach it later with POST /api/captures/{id}/receipt. An unknown key is a 400. A possible duplicate (the same receipt file already on another entry, or the same amount within 3 days of a draft or posted entry) stays a capture with possibleDuplicates [{ id, status, transaction_date, description, match: same_receipt | same_amount }] and a reason saying what to do; the capture keeps the AI's accounts (capture.ai_suggested_debit_account_id / ai_suggested_credit_account_id), so when the owner says it is a separate purchase, POST /api/captures/{id}/approve with {} drafts it, even in a later conversation. amountSource (note | receipt | hint), amountText and amountConfidence say where extractedAmount came from; a card's last four ("Visa ending 1186"), a date or a reference number is never read as the amount, and a bare number with no $ or cents, or a receipt with several amounts and no TOTAL line (amountConfidence low), stays a capture with ask_owner (also when the classifier is unavailable): ask the owner and approve with { amount }; approve without it answers 409 amount_unconfirmed. With no amount anywhere the answer is 422 code amount_missing with capture_id and ask_owner: ask the owner and approve that capture with { amount }; do not send it again. An inline receipt that yields no text is reported as receipt_read: false with receipt_note. candidateCategories (account names for a clarifying question) only with ?verbose=true. When the AI classifier is unavailable the capture is kept with classified: false, code classifier_unavailable, a plain reason and next_actions (approve with the accounts, or reject before sending it again); a card the note names by its last four is already the credit side (capture.ai_suggested_credit_account_id), also when the amount is missing. When you already know the accounts, POST /api/entries/draft is the direct path. |
PATCH /api/captures/{id} |
quickcapture:write |
Edit a capture or unposted draft from POST /api/captures. A changed note re-runs the AI (and redrafts with replaced lines when confident); accounts or amount alone only update the suggestion. 409 when the entry is posted or rejected. To edit a draft's lines directly use PATCH /api/drafts/{id}. |
POST /api/captures/{id}/approve |
quickcapture:write |
Turn a capture (or an auto-drafted capture) into a draft with one debit and one credit line, replacing any lines it has. debitAccountId and creditAccountId default to the accounts the AI picked for the capture (ai_suggested_debit_account_id / ai_suggested_credit_account_id, also on a capture held as a possible duplicate), so {} approves the AI's pick; a side with no pick answers 400 accounts_required { missing, recommended_new_account, next_actions }. amount defaults to the amount in the note, except when the capture's amount was a guess (extracted_amount_confidence low: a receipt with several amounts and no TOTAL line, or a bare number): then 409 amount_unconfirmed with ask_owner, receipt_amounts, likely_total (items plus a tax line, when the receipt has one) and a next_action to send it again with { amount } (a number: change it to what the owner says). The amount sent is stored as extracted_amount with extracted_amount_confidence owner. The draft's ai_explanation never names an account other than the ones booked or the AI's own suggestion. When the note names a card by its last four, ai_suggested_credit_account_id stays that card. Unknown account ids answer 404 before the amount check. transaction_date to the capture's date. 409 already_posted (nothing to do, with a GET /api/ledger/{id} next_action) when the entry is already posted, 409 entry_void when it was rejected. The owner posts it with POST /api/drafts/{id}/post. |
DELETE /api/captures/{id}/receipt |
quickcapture:write |
Remove the receipt from a capture or draft journal entry. A posted entry keeps its receipt (409 entry_posted). |
POST /api/captures/{id}/receipt |
quickcapture:write |
Attach or replace the receipt image/PDF on a capture or draft journal entry (the {id} from POST /api/entries/draft works), or attach a late receipt to a posted (or reconciled) entry: documentation only, amounts and accounts don't change (same as POST /api/ledger/{id}/receipt, which describes the posted-entry checks: receipt_mismatch with confirm: true, and replace: true with a reason for a posted entry that already has one). A file that does not decode as an image or PDF is 400 unreadable_file (nothing stored). JSON { filename, content_type, data_base64 } or multipart/form-data with a file field; 10 MB max; images and PDFs are read into receipt_text; when the file yields no text, the capture keeps the receipt_text it had. The same file already on another capture, draft or posted entry adds warning duplicate_receipt { duplicates, message, next_actions }. |
POST /api/captures/{id}/reject |
quickcapture:write |
Reject a capture or unposted draft (marks it void; it leaves the review queue and the books). 409 when the entry is posted: reverse it with POST /api/ledger/{id}/reverse instead. |
POST /api/captures/classify |
quickcapture:write |
Re-run AI classification on a capture from POST /api/captures (POST /api/captures already classifies new ones); a confident result becomes a draft with its lines replaced. 409 when the entry is posted or rejected. |
/api/catalog 25
| Operation | Scope | What it does |
|---|---|---|
GET /api/catalog/boms |
catalog:read |
List bills of materials (kit recipes), newest first, each with its finished-good product and component lines. Optional ?product_id (uuid) and ?is_active=true|false; a bad value is a 400. |
POST /api/catalog/boms |
catalog:write |
Create a bill of materials for a finished good: component products with a quantity per unit, plus an optional flat labor/overhead cost. Quantities must be positive, components must exist and differ from the finished good, and the recipe cannot be circular (400). Nothing is saved if a line fails. A product can have one active BOM; a second is a 409. |
DELETE /api/catalog/boms/{id} |
catalog:write |
Delete a bill of materials and its lines. Builds already made from it keep their inventory movements. To keep the recipe on file instead, PATCH is_active: false. 400 for a malformed id, 404 for an unknown one. |
GET /api/catalog/boms/{id} |
catalog:read |
One bill of materials with its finished-good product and component lines (each with the component's name and sku). 400 for a malformed id, 404 for an unknown one. |
PATCH /api/catalog/boms/{id} |
catalog:write |
Update a bill of materials: header fields, and/or lines to replace the whole component recipe. New lines get the same checks as create (400); if saving them fails the previous recipe is kept. A second active BOM for the product is a 409. |
GET /api/catalog/images/{path} |
catalog:read |
Serve the bytes of one product image from private storage by its stored path (business_id/uuid.ext, as in a product's image_gallery). Returns the image with a long immutable cache header; 404 for another business's path. |
GET /api/catalog/products |
catalog:read |
List the business's products and services (the item list for sales and purchases), sorted by name. Optional ?kind=good|service, ?active=true|false, ?q= (matches name, sku or barcode). A bad kind or active value is a 400. To fill an invoice or estimate line from an item, send its id as the line's product_id: description, unit_price (sell_price) and account_id (income_account_id) fill in when the line leaves them out, and taxable (the product's taxable, null = by kind: goods taxed, services not; taxable_effective on each product is the result). |
POST /api/catalog/products |
catalog:write |
Create a product or service. The sku is normalized (trimmed, upper-cased); a duplicate sku is a 409 duplicate_sku with existing_product (id, name, sell_price) and next_actions to update it instead. inventory_account_id and cogs_account_id must be this business's asset and expense accounts, and income_account_id an income account: an invoice or estimate line that names the product (product_id) without an account_id posts its revenue there. taxable left out stays null (by kind); the response's taxable_effective says whether its sales are taxed (a service false, a good true, unless taxable says otherwise). A taxable item while no sales tax is charged (sales_tax_handling ignore, or no rate saved) returns sales_tax_notice { note, ask_owner, next_actions }: ask the owner where they sell it (in person or shipped, from which state), decide whether tax applies, and save it with PATCH /api/settings/preferences before invoicing it (GET /api/setup/status shows the sales_tax step open until then). A good is tracked as inventory unless track_inventory: false is sent; a tracked good with no inventory_account_id, cogs_account_id or cost returns inventory_notice { code: tracked_without_inventory_setup, missing, next_actions }: for an owner who only invoices (no stock counted), PATCH it to track_inventory: false, or send track_inventory: false on create. A refused posting account is a 400 invalid_posting_account with issues [{ path, message }]. |
GET /api/catalog/products/{id} |
catalog:read |
One product or service by id, with its sku, kind, prices, cost, posting accounts and gallery. 400 for a malformed id, 404 for one not in this business. |
PATCH /api/catalog/products/{id} |
catalog:write |
Update a product or service; send only the fields to change. Deactivate with is_active: false (products are never deleted). A duplicate sku or slug is a 409; a negative price or an unknown account is a 400. |
DELETE /api/catalog/products/{id}/images |
catalog:write |
Remove one photo from a product's gallery and delete the stored file. Required ?path= is the photo's storage path exactly as it appears in the product's image_gallery[].path (business_id/uuid.jpg). Returns the remaining image_gallery. |
POST /api/catalog/products/{id}/images |
catalog:write |
Upload one product photo and append it to the gallery. JSON { filename, content_type, data_base64, alt?, match? } or multipart with file, optional alt and optional match (JSON of option values such as {"Color":"Red"} to tie the photo to variants). Returns the updated image_gallery. |
GET /api/catalog/products/{id}/uom-conversions |
catalog:read |
A product's alternate units (a case of 24, a box of 10) with factor_to_base, the number of base units in one. The base unit (the product's unit_of_measure) is factor 1 and is not listed. Returns { conversions }. |
POST /api/catalog/products/{id}/uom-conversions |
catalog:write |
Add an alternate unit to a product, e.g. uom_label "case" with factor_to_base 24 when a case holds 24 base units. Orders can then be entered in that unit. A label equal to the base unit is a 400, one the product already has a 409; 404 for an unknown product. |
DELETE /api/catalog/products/{id}/uom-conversions/{cid} |
catalog:write |
Remove an alternate unit from a product. Past orders keep the unit they were entered in. To stop offering it but keep it on file, PATCH is_active: false. 404 when the conversion is not on this product. |
PATCH /api/catalog/products/{id}/uom-conversions/{cid} |
catalog:write |
Change one alternate unit of a product: uom_label, factor_to_base (base units per this unit), is_active or metadata. Send at least one; unknown keys are a 400. A label equal to the base unit is a 400, one the product already has a 409. 404 when the conversion is not on this product. |
GET /api/catalog/products/{id}/variants |
catalog:read |
A product's variants (size, color and other option combinations), each with option_values, sku, sell_price, cost and is_active. ?active=true|false filters. Returns { variants }; 404 for an unknown product. |
POST /api/catalog/products/{id}/variants |
catalog:write |
Add a variant to a good: option_values (required, at least one option) plus optional sku, sell_price and cost that override the product's. Services cannot have variants (400). A SKU already in use is a 409. Returns { variant } with 201. |
DELETE /api/catalog/products/{id}/variants/{variantId} |
catalog:write |
Delete a variant that was never ordered. One on existing orders is a 409: PATCH is_active: false instead. 404 when the variant is not on this product. |
PATCH /api/catalog/products/{id}/variants/{variantId} |
catalog:write |
Change a variant's option_values, sku, sell_price, cost, barcode, position or is_active. is_active: false retires it and keeps its history. A SKU already in use is a 409; 404 when the variant is not on this product. |
GET /api/catalog/sku-lookup |
catalog:read |
Find stocked goods to add to an order. Covers active products of kind good only; services and inactive items never match, so use GET /api/catalog/products?q= to find those. ?q= searches sku, name and barcode and returns { kind: "search", products } (up to 25); a q that is exactly a variant's sku returns { kind: "exact_variant", product, variant }. ?product_id= returns { kind: "product", product, matrix } with the product's active variants. |
GET /api/catalog/warehouses |
catalog:read |
List the business's warehouses (stock locations), sorted by name. Optional ?active=true|false; any other value is a 400. |
POST /api/catalog/warehouses |
catalog:write |
Create a warehouse. is_default: true makes it the only default (the previous default is cleared). A duplicate code is a 409. |
GET /api/catalog/warehouses/{id} |
catalog:read |
One warehouse by id (name, code, default and active flags, address, QC settings). 400 for a malformed id, 404 for one not in this business. |
PATCH /api/catalog/warehouses/{id} |
catalog:write |
Update a warehouse; send only the fields to change. is_default: true clears the previous default. A duplicate code is a 409. |
/api/cfo 9
| Operation | Scope | What it does |
|---|---|---|
GET /api/cfo/action-queue |
|
Returns a prioritized list of actionable items across all managed businesses. Computed from pending captures, unposted drafts, and business activity. userOnly: see /api/cfo/businesses, the CFO surface is user-keyed and spans every managed business, which a per-business key principal has none of. |
GET /api/cfo/businesses |
|
Lightweight endpoint that returns just the list of managed business IDs and names. Used by components that only need the business list (e.g., AuditScopeSelector) without triggering the full financial aggregation. userOnly: the CFO surface is keyed by the acting user across every business they manage, not by one business. An API key's principal is a service identity that belongs to exactly one business and manages none, so a key would authenticate into an empty dashboard rather than a real capability. |
GET /api/cfo/financial-summary |
|
Returns revenue, expenses, and profit for each managed business. Supports optional ?period= query param (current_year, current_quarter, last_90_days). Defaults to current calendar year. userOnly: see /api/cfo/businesses, the CFO surface is user-keyed and spans every managed business, which a per-business key principal has none of. |
DELETE /api/cfo/guidance |
|
Clear the user's guidance conversation history. Deletes the conversation record; CASCADE removes all messages. |
GET /api/cfo/guidance |
|
Load the user's most recent guidance conversation history. userOnly: a guidance conversation is a human's private advisory thread, keyed by user_id across every business they manage. A per-business key principal owns no conversations and has no financial context to advise on. |
POST /api/cfo/guidance |
|
Send a message and get an AI-powered financial guidance response. |
GET /api/cfo/next-move |
|
Returns an AI-driven "next move" recommendation based on the user's financial data across all managed businesses and personal profile. Falls back to a heuristic recommendation when no AI provider is configured. userOnly: see /api/cfo/businesses, the recommendation is computed over the acting user's managed businesses and personal profile, neither of which a per-business key principal has. |
GET /api/cfo/profile |
|
Fetch the current user's personal financial profile with computed metrics. userOnly: a personal financial profile belongs to a human. A key's principal is a service identity with no personal net worth or retirement goal to report. |
PUT /api/cfo/profile |
|
Create or update the user's personal financial profile. Accepts partial updates, only provided fields are changed. |
/api/clients 1
| Operation | Scope | What it does |
|---|---|---|
GET /api/clients/{clientId}/captures |
ledger:read |
Accounting firms: the pending captures (status capture) of a client business this firm manages. 403 unless the caller's business has an active 'manages' relationship with clientId. |
/api/console 9
| Operation | Scope | What it does |
|---|---|---|
GET /api/console/runs |
|
Run history for the Padrino assistant runs viewer: the persisted console_runs rows mapped to a stable list view model. `thread` scopes to one console thread, `since` to runs started on/after an ISO timestamp. RLS-scoped db enforces business isolation (any business member can read). |
GET /api/console/runs/{runId} |
|
A single Padrino assistant run plus its persisted step trail, for the drill-down timeline. RLS-scoped db enforces business isolation; getByRunId also filters business_id. |
GET /api/console/threads |
|
List the caller's active console (agent chat) threads. Returns { threads }. User sessions only. |
POST /api/console/threads |
|
Create a console (agent chat) thread for the caller. Returns 201 { thread }. User sessions only. |
DELETE /api/console/threads/{id} |
|
Delete a console thread and stop its agent session. User sessions only. |
GET /api/console/threads/{id} |
|
Get one console thread with its messages. Returns { thread, messages }. User sessions only. |
PATCH /api/console/threads/{id} |
|
Rename a console thread or change its status. User sessions only. |
POST /api/console/threads/{id}/message |
|
Send a message (optionally with up to 4 base64 images) to a console thread. Runs the agent and streams the reply back as server-sent events. 402/429 when out of credit or rate-limited. User sessions only. |
POST /api/console/threads/{id}/warm |
|
Pre-warm a CLI session for a thread so the first message is fast. Called on page load / thread creation. Returns { sessionId }. |
/api/context 4
| Operation | Scope | What it does |
|---|---|---|
GET /api/context |
|
Start here. Who this key acts as, the business, how this API behaves (auth, errors, confirmations), the instructions written for agents, and an index of documents to read on demand. |
GET /api/context/doc |
|
Read one document listed in `documents` on GET /api/context: its markdown body and the refs it links to. |
GET /api/context/notes |
settings:read |
The notes earlier conversations saved about this business (also inlined in GET /api/context as "About this business"). notes is null until one is saved. |
PUT /api/context/notes |
settings:write |
Save what you learned from the owner so the next conversation starts knowing it: what the business does, what they use the books for, the settings you chose and how they like to work. Replaces the whole note; GET /api/context loads it into every conversation. |
/api/counts 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/counts |
ledger:read |
Sidebar counters for the app (not a financial report): pending captures and drafts (scoped to the selected location), opening-balance status, business name, and health, an all-time indicator per the business's health_mode. When the business shows drafts in health, health.value includes unposted drafts (includes_unposted_drafts: true); health.posted_value is the books' figure. For the owner's cash and this month's result use GET /api/setup/status. Not related to stock counts (/api/counts/sessions). |
POST /api/counts/capture |
counts:write |
QuickCapture natural-language / photo count sink (WMS Phase 4). An operator photographs a shelf or types/says "bin A-12-3 has 14 blue widgets"; the AI layer parses that into { product_ref, location_ref, counted_qty } observations, this route resolves each to a real product + location, and stages the counted quantity onto an open count session, matching an existing sheet line or adding one, opening a spot session when none is running. Staging never posts on-hand; the operator still reviews and closes the count (AI never closes it). Barcode/AI is an input method over existing entities: no new scanned-count data model, staged quantities land on ordinary stock_count_lines. Input: { text?, image_path?, observations?, session_id?, warehouse_id? }. Callers may pass pre-parsed `observations` (skip the AI) or free text/photo. |
GET /api/counts/sessions |
counts:read |
Stock-count sessions (physical inventory counts), newest first. Optional warehouse_id (UUID), status (open | counting | review | closed | cancelled), limit 1-200; a bad value or unknown parameter is a 400. Closing a session posts count movements; it does not touch the ledger directly. |
POST /api/counts/sessions |
counts:write |
Open a count session and generate its sheet in one shot. The scope filter selects the product×location buckets to count; each surviving bucket's current on-hand is snapshotted as expected_qty. Open sessions never touch on-hand, only closing the session posts count movements (see PATCH [id]). |
GET /api/counts/sessions/{id} |
counts:read |
The session header plus its lines. While a blind session is still being counted, expected_qty and variance are redacted from the lines so the counter can't see the book figure; they reappear once the session hits review/closed. |
PATCH /api/counts/sessions/{id} |
counts:write |
Advance the status machine and/or edit header fields (notes, scheduled_for). The transition to `closed` runs the close orchestration: one `count` movement per non-zero-variance line posts through the single inventory write path (reference_type=stock_count). Every other transition is a plain status update; open sessions never touch on-hand. |
GET /api/counts/sessions/{id}/anomalies |
counts:read |
Variance-anomaly flags for a counted session (WMS Phase 4, AI-native layer). Only meaningful once variance exists, so it returns nothing until the session is in review/closed. Builds a per-product signal from the movement ledger (prior shrink counts, recent net movement) and runs the pure detector; each flag traces to an explicit reason rather than an opaque score. |
POST /api/counts/sessions/{id}/lines |
counts:write |
Enter a counted quantity against one line. This is also the scan / QuickCapture sink: a line is addressed either by line_id or by (product_id, location_id) when a scanner only knows what it scanned. Recording a count computes and stores its variance against the expected snapshot and stamps who/when; it does NOT touch on-hand (that happens only at close). The first count on an `open` session advances it to `counting`. |
/api/credit-memos 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/credit-memos |
ledger:read |
List credit memos (money owed back to customers), newest first, with lines, applications to invoices, refunds and remaining_credit. Filters: customer_id, status (open = has unused credit, closed, void), invoice_id (issued against or applied to that invoice). |
POST /api/credit-memos |
ledger:write |
Issue a credit memo to a customer (a price concession: it moves no money and books no cash; for money a customer already paid that no invoice needs, such as a retainer, deposit, duplicate payment or overpayment, use POST /api/customers/{id}/credits and pay the next invoice with from_customer_credit instead): { customer_id, lines: [{ description, quantity?, unit_price, account_id? or account_code? }], tax_amount?, credit_date?, source_invoice_id?, apply_to_invoice_id?, reason? }. Numbered CM-####. Use it to correct a billing mistake or give a goodwill credit on a sent invoice (apply_to_invoice_id lowers its balance_due and AR aging; clearing the balance marks it paid); unapplied memo credit nets the customer's AR aging. Not for money a customer paid up front (a retainer, deposit or prepayment): that is POST /api/customers/{id}/credits. Posts no ledger entry, except when applied to an opening-balance invoice carried over from another system (partly paid or not): that application posts Dr sales returns (or the lines' accounts) / Cr the invoice's opening_balance_account_id on credit_date (posted_entry_id; 409 closed_period unless confirmClosedPeriod). When apply_to_invoice_id can't be applied (future credit_date on an opening-balance invoice, closed period, over the balance), the call fails and creates no memo (credit_memo_created: false). Refund it with POST /api/credit-memos/{id}/refund to pay cash back. For a paid invoice, POST /api/invoices/{id}/refund does memo + refund in one call. Any key not listed in the body is a 400 with a did-you-mean hint (invoice_id: did you mean apply_to_invoice_id?; date: did you mean credit_date?). |
GET /api/credit-memos/{id} |
ledger:read |
One credit memo with its lines, invoice applications, cash refunds (each with posted_entry_id) and remaining_credit. |
POST /api/credit-memos/{id}/applications/{appId}/void |
ledger:write |
Undo a credit memo applied to the wrong invoice: { reason, confirmClosedPeriod?, confirmReconciledPeriod? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. appId is from invoice_credit_applications on GET /api/credit-memos/{id} (the 409 memo_used of the memo void lists one call per application). Hands the amount back to the memo (amount_applied drops, status open, remaining_credit) and to the invoice (credit_applied drops; a paid invoice reopens as sent with balance_due), and keeps the application with voided_at, void_reason and voided_by; later payments and the memo lists skip it. An ordinary application posted nothing, so nothing posts; one onto an opening-balance invoice gets the mirror of its entry on its own date (void_entry_id). Then apply the memo to the right invoice with POST /api/credit-memos/{id}/apply. 404 credit_memo_not_found, application_not_found, invoice_not_found; 409 already_voided, memo_void, invoice_not_open (cancelled), amounts_changed, status_changed, reversal_draft_pending; 409 settled_by_payment when a payment recorded after the application settled the invoice net of the credit (next_actions: void that payment, undo the application, record the payment again); closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true. |
POST /api/credit-memos/{id}/apply |
ledger:write |
Apply a credit memo to an open (sent) invoice for the same customer: { invoice_id, amount?, applied_date? }. amount defaults to the smaller of the remaining credit and the invoice's balance_due. Lowers balance_due and AR aging without cash and posts nothing; later payments on the invoice recognize proportionally less revenue and sales tax. On an opening-balance invoice (carried over from another system, partly paid or not) the application posts instead, on applied_date: Dr sales returns (or the memo lines' accounts) plus the memo's sales tax share out of sales tax payable / Cr the invoice's opening_balance_account_id, so that account and AR aging drop together (posted_entry_id, opening_balance_invoice: true); 409 applied_before_credit, 409 closed_period unless confirmClosedPeriod. Clearing the balance marks the invoice paid. Applied to the wrong invoice: undo it with POST /api/credit-memos/{id}/applications/{appId}/void (application.id). 409 codes: memo_not_open, customer_mismatch, invoice_not_open (draft: edit it; paid: refund instead), nothing_due, exceeds_remaining, exceeds_balance. |
POST /api/credit-memos/{id}/refund |
ledger:write |
Pay a credit memo's remaining credit back to the customer in cash: { amount?, refund_date?, from_account_id?, bank_transaction_id?, payment_method?, reference?, confirmClosedPeriod?, confirmReconciledPeriod? }. amount defaults to all remaining credit. Posts one entry on refund_date: Dr each line's account (default the sales_returns role, else sales_discounts, else revenue) and Dr sales tax payable for the memo's tax share, Cr from_account_id (default the cash role (it must be named when the business has more than one cash or bank account: 400 deposit_account_required lists at most 5 (accounts_total and more_accounts for the rest), the account the memo's source invoice was paid into first (else where the customer last paid), each with why, and a next_action per account)). Entries made with an API key carry source api. Returns posted_entry_id and refund.id; a refund recorded in error or on the wrong day is undone with POST /api/credit-memos/{id}/refunds/{refund.id}/void, never a ledger reverse. 400 refund_before_credit (with earliest_refund_date) when refund_date is before the memo's credit_date. 409 memo_not_open, closed_period, reconciled_period when the date is on or before the statement date of that account's last completed bank reconciliation (resend with confirmReconciledPeriod: true once the owner agrees, or reopen it), exceeds_remaining (amount over the remaining credit, remaining_credit); 400 invalid_from_account. bank_transaction_id names the bank-feed row (money out) this refund is: amount and refund_date default to the row's (a row dated a day after today, as the import allows, refunds on that date), from_account_id to its bank account (400 from_account_mismatch for another), 400 bank_amount_mismatch for a different amount, 409 transaction_already_matched when the row is already in the books, 409 bank_row_other_customer when the row's text names a customer other than the memo's (rows_for_customer: the open rows that name the memo's customer, any amount, amount_matches true first, a next_action for each that matches) unless confirm_other_customer; after posting the row is linked to the refund entry (bank_transaction { id, status matched, matched_entry_id }). |
POST /api/credit-memos/{id}/refunds/{refundId}/void |
ledger:write |
Undo a credit memo cash refund recorded in error (not sent yet), on the wrong date or from the wrong bank: { reason, confirmClosedPeriod?, confirmReconciledPeriod? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. refundId is refund.id from POST /api/credit-memos/{id}/refund, or an id in credit_memo_refunds on GET /api/credit-memos/{id}. Posts the mirror of the refund's entry on the refund's own date (void_entry_id; a posted ledger reversal of it is reused instead), lowers the memo's amount_refunded (it reopens with remaining_credit, back in A/R aging as customer credit), sends the bank-feed row matched to the refund back to unmatched (bank_rows_released), and keeps the refund with voided_at, void_reason and void_entry_id. Then record the refund again with POST /api/credit-memos/{id}/refund, apply the credit to an invoice, or void the memo with POST /api/credit-memos/{id}/void once nothing is applied or refunded. Never undo a refund with POST /api/ledger/{id}/reverse (409 document_entry). 400 invalid_id, 404 credit_memo_not_found, refund_not_found, 409 already_voided, status_changed, reversal_draft_pending, closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true. |
POST /api/credit-memos/{id}/void |
ledger:write |
Void a credit memo issued in error, while none of it has been applied or refunded: { reason? }. Keeps the number, status 'void', reason in metadata.void_reason; it stops counting as customer credit. 409 memo_used once any of it was applied or refunded (next_actions: POST /api/credit-memos/{id}/applications/{appId}/void for each invoice it is applied to and the void of each cash refund, each putting the amount back on the memo, then this void again), 409 already_void. |
/api/crm 238
| Operation | Scope | What it does |
|---|---|---|
GET /api/crm/activities |
crm:read |
List CRM activities (notes, calls, emails, tasks). Filter by contact_id or deal_id, type (an activity_type: note, call, email, meeting, chat_transcript, sms, task; activity_type= works too), after/before dates, limit (1-500, default 50) and offset (with contact_id or deal_id); pending_tasks=true returns open tasks. Returns { activities }. |
POST /api/crm/activities |
crm:write |
Log a CRM activity (note, call, email, task, etc.) on a contact and/or deal. @mentions in the body notify teammates; a task defaults its owner to the caller. Returns 201 { activity }. |
DELETE /api/crm/activities/{id} |
crm:write |
Delete a CRM activity by id. Returns { success: true }, or 404 Activity not found. |
GET /api/crm/activities/{id} |
crm:read |
Get one CRM activity by id. Returns { activity }, or 404. |
PUT /api/crm/activities/{id} |
crm:write |
Update a CRM activity's title, body, due date, completion, pin, owner, mentions or metadata. Only sent fields change. Returns { activity }, or 404. |
DELETE /api/crm/activities/{id}/complete |
crm:write |
Undo completing a CRM follow-up (clears its completion). Returns { activity }, or 404. |
PUT /api/crm/activities/{id}/complete |
crm:write |
Mark a CRM follow-up done, recording who did it. Returns { activity }, or 404. DELETE undoes it. |
POST /api/crm/activities/{id}/send |
outreach:send |
Send one drafted outreach activity as email and mark it sent. Body (all optional overrides): { to, subject, html } |
PUT /api/crm/activities/{id}/snooze |
crm:write |
Push a CRM follow-up out: preset (counted forward from today) or due_date. Neither is a 400 with issues; a missing id is 404 Follow-up not found. Returns { activity }. |
GET /api/crm/booking-links |
crm:read |
The business's meeting booking links (a public page where a lead picks a time with a rep or a team pool). ?user_id= only that member's links; ?active_only=1 only active ones; ?include_members=1 adds each team link's members. Returns { links }. |
POST /api/crm/booking-links |
crm:write |
Create a meeting booking link; user_id defaults to the caller. Returns 201 { link, members }; 409 if the slug is taken. |
DELETE /api/crm/booking-links/{id} |
crm:write |
Delete a booking link and its public booking page. Returns { ok: true } or 404 when no link has that id. To stop bookings but keep the link, PATCH is_active: false instead. |
PATCH /api/crm/booking-links/{id} |
crm:write |
Update a booking link (slug, title, duration, timezone, is_active, resource/service, team pool members). Returns { link, members }, 404 when no link has that id, 409 when the slug is taken. |
GET /api/crm/business-types |
crm:read |
List the business's CRM business types (the curated trade list companies are classified as), each with company_count. Returns { types }. |
POST /api/crm/business-types |
crm:write |
Create a CRM business type (name, optional slug, keywords that match a company's free-text industry). Returns 201 { type }. |
DELETE /api/crm/business-types/{id} |
crm:write |
Delete a CRM business type; its companies keep existing and fall back to the default wish set. Returns { ok: true }, or 404. |
PATCH /api/crm/business-types/{id} |
crm:write |
Update a CRM business type's name, slug, keywords or sort order (at least one field). Returns { type }. |
GET /api/crm/business-types/{id}/wishes |
crm:read |
Get the example wishes shown on flyers for leads of this business type, in display order. Returns { examples }, or 404. |
PUT /api/crm/business-types/{id}/wishes |
crm:write |
Replace the business type's example wishes as a whole; array order is display order. Returns { examples }. |
POST /api/crm/business-types/suggest |
crm:write |
Propose a business type for every company without one by matching type names and keywords against the company's industry; apply: true writes them. Returns the proposals. |
POST /api/crm/call-summary |
crm:read |
AI-summarize a sales call from a transcript and/or notes, optionally labeled with a deal or contact. Saves nothing; returns the generated summary. |
GET /api/crm/campaigns |
crm:read |
Marketing campaigns: one-shot bulk email ("newsletter") to a list -- the third consumer of crm_lists after the list-view UI and sequence enrollment. GET lists campaigns for the business; POST creates one (draft, or scheduled when scheduled_at is set). The actual send is POST /api/crm/campaigns/[id]/send. |
POST /api/crm/campaigns |
crm:write |
Create a marketing email campaign to a contact list: a draft, or scheduled when scheduled_at is set. Send it with POST /api/crm/campaigns/{id}/send. Returns 201 { campaign }. |
DELETE /api/crm/campaigns/{id} |
crm:write |
Delete a marketing campaign by id. Returns { ok: true }, or 404. |
GET /api/crm/campaigns/{id} |
crm:read |
One marketing campaign plus its live open, click and reply counts. Returns { campaign, engagement }; 404 if no campaign has that id. |
PATCH /api/crm/campaigns/{id} |
crm:write |
Edit a draft or scheduled campaign's name, subject, body or scheduled_at (null unschedules back to draft). Sent campaigns return 409. Returns { campaign }. |
POST /api/crm/campaigns/{id}/send |
outreach:send |
Send a campaign to its list now. Every recipient funnels through the same sendOutreachEmail choke point the single/batch/sequence sends use, so suppression, the marketing-consent gate, open/click tracking, and the unsubscribe footer all apply unchanged. `outreach:send` scoped, matching the other real-send routes (CRUD is crm:read/crm:write). |
POST /api/crm/campaigns/draft |
crm:write |
AI-draft a campaign email (name, subject, HTML body) from a one-line prompt, as prefill for POST /api/crm/campaigns. Saves nothing; returns { draft }. |
GET /api/crm/capture-address |
crm:read |
Get the business's inbound email-capture address (emails forwarded there are captured into the CRM). Returns { address, has_token, mailbox_configured }; address is null until minted and the mailbox is configured. |
POST /api/crm/capture-address |
crm:write |
Mint the business's email-capture address token (idempotent). Takes no body. Returns { address, has_token: true, mailbox_configured }. |
GET /api/crm/chat-logs |
crm:read |
The messages in one chat conversation with this business's AI assistant (owner console or website chat). Required ?thread_id= (a UUID for an owner console thread, demo-... for a website chat). Returns { logs }. |
GET /api/crm/companies |
crm:read |
List CRM companies, filtered by search, industry, location_id, owner, with limit/offset paging; ?deleted=true lists the recycle bin instead. Returns { companies, count, limit, offset }. |
POST /api/crm/companies |
crm:write |
Create a CRM company (owner defaults to the caller). Returns 409 { code: "duplicate_company", duplicates } when it likely already exists; resend with allow_duplicate: true to create anyway. Returns 201 { company }. |
DELETE /api/crm/companies/{id} |
crm:write |
Soft-delete a CRM company into the recycle bin (restore with POST /api/crm/companies/{id}/restore). Returns { success: true }, or 404. |
GET /api/crm/companies/{id} |
crm:read |
Get one CRM company by id. Returns { company }, or 404. |
PUT /api/crm/companies/{id} |
crm:write |
Update a CRM company; only sent fields change, null clears a nullable field. Custom fields are validated against the business's definitions. Returns { company }, or 404. |
GET /api/crm/companies/{id}/ar |
crm:read |
Read-only accounting rollup for a company's linked customer: open AR balance, open invoices, and recent payments. Returns { linked: false } when the company has no accounting customer yet. |
PUT /api/crm/companies/{id}/business-type |
crm:write |
Classify a company as a CRM business type (decides the example wishes on its flyers), or clear it with null. Returns { ok: true }. |
POST /api/crm/companies/{id}/enrich |
crm:write |
On-demand AI firmographic enrichment for a single company. Fetches the company's own website, extracts firmographics with Haiku, and fills only currently-blank fields (unless ?overwrite). ?preview=true returns the proposed field notes without writing. Writes go through updateCrmCompany so territory rules and pipeline scoring (field_changed) fire on the update. |
GET /api/crm/companies/{id}/hierarchy |
crm:read |
The company plus its parent (id/name) and its list of subsidiaries. |
POST /api/crm/companies/{id}/link-customer |
crm:write |
Link this CRM company to an accounting customer (the one bridge from CRM to invoicing and receivables): { customer_id } links an existing customer from GET /api/customers, { create: true } makes a new customer from the company's name and email. Returns { customer, company }. |
POST /api/crm/companies/{id}/logo |
crm:write |
Discover the company's logo from its website and store it as logo_url. Takes no body. Returns the discovery result. |
PUT /api/crm/companies/{id}/logo |
crm:write |
Set or clear (null) a company's logo_url by hand. Returns the stored result. |
PUT /api/crm/companies/{id}/parent |
crm:write |
Link this company as a subsidiary of another (parent_company_id), or unlink it (parent_company_id: null). Depth/cycle rules live in linkCompanyParent. |
POST /api/crm/companies/{id}/restore |
crm:write |
Restore a deleted CRM company out of the recycle bin. 404 when the id is unknown or the company is not in the bin. |
GET /api/crm/companies/{id}/rollup |
crm:read |
Deal pipeline value (open_value, won_value, total_value) and activity count across a CRM company and its direct subsidiaries. Pipeline, not booked revenue: use GET /api/crm/companies/{id}/ar for invoiced and paid amounts. |
GET /api/crm/companies/duplicates |
crm:read |
Find CRM companies that look like the same company. With ?name= and/or ?website= it checks whether that company already exists and returns { matches }; without them it scans the latest ?limit= companies (default 200) for ones sharing a website domain or exact name and returns { groups, scanned, total }. |
POST /api/crm/companies/enrich |
crm:write |
Bulk AI firmographic enrichment for up to 25 company ids, run sequentially from each company's website; fills blank fields unless overwrite: true, ?preview=true proposes without writing. Returns { preview, enrichedCount, results }. |
POST /api/crm/companies/merge |
crm:write |
Fold duplicate companies into one, the company mirror of contacts/merge. Contacts and deals are repointed to the survivor before the losers are deleted, so nothing is orphaned by the ON DELETE SET NULL on their company_id. Not reversible; the UI must confirm before calling this. |
GET /api/crm/consents |
crm:read |
The marketing-consent audit trail (opt-in grants and revocations for email or text marketing), newest first. Filters ?channel= email | sms, ?identifier= an email or phone, ?limit= 1-500 (default 50), ?offset=. Returns { consents }. |
POST /api/crm/consents |
crm:write |
Record a marketing-consent grant or revocation (status: granted | revoked) for an email or phone identifier on a channel, appended to the audit trail. Returns 201 { consent }. |
GET /api/crm/contacts |
crm:read |
List CRM contacts (people), filtered by search, source, company_id, tags (comma list), min_score, pipeline_ids, owner, sort=score, with limit/offset paging; ?deleted=true lists the recycle bin instead. Returns { contacts, count, limit, offset }. These are CRM people (sales prospects), not accounting customers: to invoice someone, use /api/customers. |
POST /api/crm/contacts |
crm:write |
Create a person. Returns 409 with the matching records when the payload strongly duplicates an existing person (same email, same phone). The caller decides what to do, open the existing record, merge, or resend with `allow_duplicate: true`. Silently merging on create would be worse than the duplicate it prevents: two different people can share a phone line, and an automatic merge is not undoable. |
DELETE /api/crm/contacts/{id} |
crm:write |
Soft-delete a CRM contact into the recycle bin (restore with POST /api/crm/contacts/{id}/restore). Returns { success: true }, or 404. |
GET /api/crm/contacts/{id} |
crm:read |
Get one CRM contact by id. Returns { contact }, or 404. |
PUT /api/crm/contacts/{id} |
crm:write |
Update a CRM contact; only sent fields change, null clears a nullable field. Custom fields are validated against the business's definitions. Returns { contact }, or 404. |
GET /api/crm/contacts/{id}/ar |
crm:read |
What a contact owes. Invoice AR from the linked accounting customer (linked: false when none) plus non_invoice_receivables: loans and other receivables (e.g. 1250 Loans Receivable) on journal entries linked to this contact by crm_contact_id, drafts and posted, with running balances and posted_balance/draft_balance/balance. unlinked_counterparty_suggestions lists entries whose free-text counterparty matches the contact's name: confirm with the owner, then link each with PUT /api/ledger/{id}/contact. |
DELETE /api/crm/contacts/{id}/companies |
crm:write |
Remove the contact's association with the company given by ?company_id=. Returns { ok: true }; 404 if the contact or the link does not exist. |
GET /api/crm/contacts/{id}/companies |
crm:read |
The companies a CRM person is linked to besides their main company_id, each with a role and is_primary flag. Returns { companies }. |
PATCH /api/crm/contacts/{id}/companies |
crm:write |
Update the contact's role and/or is_primary flag at one associated company (company_id in the body). Returns { company }, or 404. |
POST /api/crm/contacts/{id}/companies |
crm:write |
Associate the contact with an additional company (beyond its primary company_id), with an optional role and is_primary flag. Returns 201 { company } (the association row). |
POST /api/crm/contacts/{id}/data-request |
crm:write |
Privacy (GDPR/CCPA) data-subject request on one CRM contact. { mode: export } returns the portable bundle of their data; { mode: erase } returns what would be erased; { mode: erase, confirm: true } stages the erasure. Both actioned modes are written to the audit log. |
GET /api/crm/contacts/{id}/engagement |
crm:read |
Email engagement for one contact: an opens/clicks/replies rollup plus the event list. Returns { rollup, events }. |
POST /api/crm/contacts/{id}/enrich |
crm:write |
On-demand AI enrichment for a single contact. Contacts have no firmographic columns of their own, so the signal comes from the contact's email domain (skipping free-mail providers): we fetch that company homepage and land the firmographics in the contact's custom_fields. ?preview=true returns the proposal without writing. Writes go through updateCrmContact so field_changed automation fires. |
POST /api/crm/contacts/{id}/link-customer |
crm:write |
Link this CRM contact to an accounting customer (the one bridge from CRM to invoicing and receivables): { customer_id } links an existing customer from GET /api/customers, { create: true } makes a new customer from the contact's name and email. Returns { customer, contact }. |
POST /api/crm/contacts/{id}/promote-to-company |
crm:write |
Turn a contact with no company into one: creates the company (named from the person unless name is given), links the person to it and moves their address up. Returns 201 { company }, or 409 when not possible. |
POST /api/crm/contacts/{id}/restore |
crm:write |
Restore a deleted CRM contact out of the recycle bin. 404 when the id is unknown or the contact is not in the bin. |
GET /api/crm/contacts/duplicates |
crm:read |
Existing people that look like the same person, for the cleanup screen. Scans the most recent `limit` contacts and matches each against the rest. Bounded on purpose: an all-pairs scan over a large business would be both slow and mostly noise, and the operator only ever acts on a page of results at a time. The response says what was scanned so the UI can be honest about coverage instead of implying the whole list is clean. With `?email=&phone=` it answers the narrower question, does this specific person already exist, which is what the create form calls before showing a duplicate warning. |
POST /api/crm/contacts/merge |
crm:write |
Fold duplicate people into one. Deals and activities are repointed to the survivor before the losers are deleted, so nothing is lost to the ON DELETE CASCADE on crm_activities.contact_id. Not reversible, the UI must confirm before calling this. |
GET /api/crm/custom-fields |
crm:read |
List CRM custom field definitions. Optional query: object_type (contact, company or deal) and include_archived=true|false. Returns { fields }. |
POST /api/crm/custom-fields |
crm:write |
Define a new custom field on contacts, companies or deals. Returns 201 { field }; 409 duplicate_key if that object type already has the key. |
DELETE /api/crm/custom-fields/{id} |
crm:write |
Delete a custom field definition. Returns { success: true } or 404. To hide it but keep its data, PUT archived: true instead. |
PUT /api/crm/custom-fields/{id} |
crm:write |
Update a custom field definition (label, type, options, required, sort order, archived); the key and object type are fixed. Returns { field } or 404. |
GET /api/crm/custom-fields/infer |
crm:read |
Suggest custom field definitions by sampling the custom_fields values already stored on records of object_type (required query: contact, company or deal). Returns { suggestions, sampled }. |
POST /api/crm/custom-fields/reorder |
crm:write |
Set the display order of custom fields for one object type; ordered_ids lists every field id in the new order. Returns { success: true }. |
GET /api/crm/dashboard |
crm:read |
CRM dashboard for one pipeline board (query pipeline_id, else the default board, created on first visit; optional location_id). Returns pipeline summary, overdue tasks, outreach drafts, leads past the first-response SLA, stale deals, source breakdown and email deliverability. |
GET /api/crm/deals |
crm:read |
List CRM deals (sales pipeline). A deal's value is an expected sale, not booked revenue: invoices and payments are in /api/invoices. Query filters: pipeline_id, stage_id, contact_id, location_id, search, tags (repeat or comma-separated, any match), closed=true|false, owner, min_score, sort=score, limit (1-500, default 50), offset; deleted=true lists the recycle bin instead. Returns { deals, count } where count is the total before paging. |
POST /api/crm/deals |
crm:write |
Create a deal on a pipeline stage (pipeline and stage ids from GET /api/crm/pipelines). Owner defaults to the calling user unless owner_id or owner_employee_id is given. Returns 201 { deal }. |
DELETE /api/crm/deals/{id} |
crm:write |
Move a deal to the recycle bin (restore with POST /api/crm/deals/{id}/restore). A high-value deal or one with logged activity returns 409 confirmation_required with reasons; retry with ?confirm=true. Returns { success: true }. |
GET /api/crm/deals/{id} |
crm:read |
Get one deal by id. Returns { deal } or 404. |
PUT /api/crm/deals/{id} |
crm:write |
Update a deal's fields. Setting stage_id moves it within its current board (also returns requirements_status for the new stage); pipeline_id is rejected here, use PUT /api/crm/deals/{id}/pipeline. Returns { deal }. |
GET /api/crm/deals/{id}/collaborator-suggestions |
crm:read |
Pending AI suggestions of teammates to add as collaborators on a deal, limited to deals the caller can see. Accept or dismiss one under /api/crm/deals/{id}/collaborator-suggestions/{suggestionId}. |
POST /api/crm/deals/{id}/collaborator-suggestions/{suggestionId}/accept |
crm:write |
Accept a pending suggestion: adds the proposed teammate as a deal collaborator (visibility folds into salesman RLS via crm_collaborator_deal_ids) and marks the suggestion accepted. The AE confirms here, visibility is never granted silently. |
POST /api/crm/deals/{id}/collaborator-suggestions/{suggestionId}/dismiss |
crm:write |
Dismiss a pending suggestion. A dismissed teammate is not re-proposed for this deal (the generator skips pending/dismissed rows), so the AE's "no" sticks instead of resurfacing on the next mention. |
DELETE /api/crm/deals/{id}/collaborators |
crm:write |
Remove a collaborator from a deal. Required query: user_id. Returns { ok: true }. |
GET /api/crm/deals/{id}/collaborators |
crm:read |
The team members working a deal alongside its owner, each with an optional role label; they see the deal as its owner does. Returns { collaborators }. |
POST /api/crm/deals/{id}/collaborators |
crm:write |
Add a team member as a collaborator on a deal, with an optional role label; collaborators see the deal like its owner. Returns 201 { collaborator }; 400 if the user is the deal's owner. |
GET /api/crm/deals/{id}/detail |
crm:read |
Assembles the same payload the deal detail page loads (deal + activities + pipeline stages + email engagement) so the route planner can render the full DealDetail component inside a modal. Interface-agnostic: any client can fetch it, not just the planner. |
GET /api/crm/deals/{id}/history |
crm:read |
Every step this lead has moved through, oldest first, with how long it sat in each. Duration is computed here rather than stored so it stays correct for the current step, which has no end timestamp yet. |
PUT /api/crm/deals/{id}/pipeline |
crm:write |
Move a deal to a different pipeline (board). This is the ONLY supported way to change a deal's pipeline_id. The base `PUT /api/crm/deals/[id]` rejects pipeline_id so a caller can't set it without a validated target stage, the split-field state (pipeline_id and stage_id on different boards) is exactly what broke ticket 39e14898. stage_id is optional: omit it to land the deal in the target pipeline's first stage. moveDealStage writes pipeline_id and stage_id together and records stage history. |
DELETE /api/crm/deals/{id}/qr-link |
crm:write |
Release the QR code currently pointing at this deal. Returns { ok: true }. |
GET /api/crm/deals/{id}/qr-link |
crm:read |
Get the printed QR code (for example a flyer code) that points at this deal. Returns { link }, null when none. |
PUT /api/crm/deals/{id}/qr-link |
crm:write |
Point a printed QR code (4 to 16 letters or numbers) at this deal. Returns { link }; 409 with the other deal when the code is already on one, unless move: true. |
POST /api/crm/deals/{id}/qualify |
crm:write |
Investigate this deal's prospect and record why they are worth working: what software they appear to run, what still hurts given those tools, and how reachable their stack is (1198_crm_deal_qualification.sql). Synchronous on purpose: this is the rescore button on the deal page, and the operator is waiting to read the answer. A drain of the whole backlog belongs on /api/cron/crm-deal-qualification instead. |
POST /api/crm/deals/{id}/restore |
crm:write |
Restore a deleted CRM deal out of the recycle bin. 404 when the id is unknown or the deal is not in the bin. |
PUT /api/crm/deals/{id}/stage |
crm:write |
Move a deal to a different stage. `closed_at` is NOT accepted from the client, it is derived from the target stage. An earlier version trusted a client-supplied value, which let a stale tab or an API caller mark a deal Won with no close timestamp. |
GET /api/crm/deals/{id}/stakeholder-suggestions |
crm:read |
Pending AI suggestions of contacts to add to a deal's buying committee, limited to deals the caller can see. Accept or dismiss one under /api/crm/deals/{id}/stakeholder-suggestions/{suggestionId}. |
POST /api/crm/deals/{id}/stakeholder-suggestions/{suggestionId}/accept |
crm:write |
Accept a pending suggestion: attaches the proposed contact to the deal's buying committee (crm_deal_contacts) and marks the suggestion accepted. The AE confirms here, a stakeholder is never attached silently. |
POST /api/crm/deals/{id}/stakeholder-suggestions/{suggestionId}/dismiss |
crm:write |
Dismiss a pending suggestion. A dismissed contact is not re-proposed for this deal (the generator skips pending/dismissed rows), so the AE's "no" sticks instead of resurfacing on the next mention. |
DELETE /api/crm/deals/{id}/stakeholders |
crm:write |
Remove a contact from a deal's buying committee. Required query: contact_id. Returns { ok: true }. |
GET /api/crm/deals/{id}/stakeholders |
crm:read |
A deal's buying committee: the contacts on it beyond its primary contact, each with role and is_primary. Returns { stakeholders }. |
PATCH /api/crm/deals/{id}/stakeholders |
crm:write |
Change a stakeholder's role or is_primary flag on a deal, picked by contact_id. Returns { stakeholder } or 404. |
POST /api/crm/deals/{id}/stakeholders |
crm:write |
Add a contact to a deal's buying committee, with an optional role and is_primary flag. Returns 201 { stakeholder }; 404 if the deal or contact is not found. |
GET /api/crm/deals/{id}/wishes |
crm:read |
Get this deal's own example wishes for its flyer, plus the effective set the flyer would show today and where it comes from (deal, business type or default). Returns { examples, effective }. |
PUT /api/crm/deals/{id}/wishes |
crm:write |
Replace this deal's own example wishes; an empty array falls back to the business type or default set. Returns { examples, effective }. |
POST /api/crm/deals/generate-demo |
crm:write |
Start building an AI demo site and case study for a deal's business on the demo service. Returns 202 { demoId, subdomain, status: "generating" } and records the demo on the deal's custom fields; 503 if the demo service is not configured. |
GET /api/crm/duplicates/suggestions |
crm:read |
Pending near-duplicate review cards, strongest score first. These are written by the generate route; approving one performs the human-gated merge. `?entity_type=contact|company` narrows to one kind. |
POST /api/crm/duplicates/suggestions/{id}/approve |
crm:write |
Approve a review card: perform the human-gated merge of the pair, mark the suggestion approved, and dismiss any sibling pending suggestion that referenced either merged record. Not reversible; the UI must confirm first. |
POST /api/crm/duplicates/suggestions/{id}/dismiss |
crm:write |
Rule out a review card without merging: the two records are not the same. A dismissed pair stays dismissed: generation skips it on later runs so it doesn't resurface every cycle. |
POST /api/crm/duplicates/suggestions/generate |
crm:write |
Scan the book for fuzzy near-duplicates and persist each as a pending review card. Idempotent per pair: pairs already pending or previously dismissed are left alone. The AI adjudicator writes a plain-English rationale on borderline pairs; it never decides or performs a merge (approve is human-gated). |
GET /api/crm/export |
crm:read |
Download CRM records as CSV. Query entity=contacts|companies|deals (default contacts) plus the same filters the list views take (search, owner, min_score, lifecycle_stage, location_id, custom_field_key/op/value, and per-entity filters such as pipeline_id, stage_id, closed, industry, source, company_id, tags). Returns text/csv, up to 50,000 rows. CRM only: deal Value / Weighted Value are pipeline, not revenue; for invoices, bills or financial reports use /api/invoices, /api/bills or /api/reports. |
GET /api/crm/field-breakdown |
crm:read |
Count the values of one custom field across records, grouped by a standard column (for example what software companies in each industry run). Query: object_type (default company), key (custom field key, required), group_by, top. Returns the field, the grouping and per-group value counts. |
GET /api/crm/forecast |
crm:read |
Sales pipeline analytics: weighted forecast of open deal value by stage and expected-close month, win rates and stage velocity. These are CRM deal estimates, not booked revenue: to answer how much the business made, use GET /api/reports/profit-and-loss. Managers only (403 otherwise). |
GET /api/crm/forms |
crm:read |
List this business's lead-capture forms. Returns { forms }. |
POST /api/crm/forms |
crm:write |
Create a lead-capture form from a field set (POST /api/crm/forms/draft can compile one from plain English first). Returns 201 { form }. |
DELETE /api/crm/forms/{id} |
crm:write |
Delete a lead-capture form. Returns { ok: true } or 404. |
GET /api/crm/forms/{id} |
crm:read |
Get one lead-capture form by id. Returns { form } or 404. |
PATCH /api/crm/forms/{id} |
crm:write |
Replace a lead-capture form's name, fields, status, thank-you message and authoring source (name and fields are required). Returns { form } or 404. |
POST /api/crm/forms/draft |
crm:write |
Compile a plain-English description of a lead-capture form into a draft field set with AI. Nothing is saved: returns { draft, summary, valid: true } ready for POST /api/crm/forms, or { draft, valid: false, issues } to correct first. |
GET /api/crm/history |
crm:read |
Field-change history for one CRM record, newest first. Required query: resource_type (crm_contact, crm_company or crm_deal) and resource_id (UUID). Returns { entries } with actor names. |
GET /api/crm/import/batches |
crm:read |
Import history for the business: every committed CSV import, newest first, with counts, the mapping used, and whether it has been undone. |
POST /api/crm/import/batches/{id}/undo |
crm:write |
Reverse an import: soft-delete the rows it created (recoverable from the recycle bin) and restore the fields it overwrote on existing records. |
POST /api/crm/import/commit |
crm:write |
Write the CRM import rows the operator approved (after POST /api/crm/import/preview); a bad row is reported and the rest still import. Undo with POST /api/crm/import/batches/{id}/undo. CRM contacts and companies only; to move accounting customers and vendors use POST /api/customers/import and POST /api/vendors/import (invoices and A/R need those). |
POST /api/crm/import/preview |
crm:write |
Parse, map, validate and duplicate-check a CSV of CRM contacts or companies without writing anything, so the operator sees what a commit would do. CRM contacts and companies only; to move accounting customers and vendors use POST /api/customers/import and POST /api/vendors/import (invoices and A/R need those). |
GET /api/crm/lead-gen/configs |
crm:read |
List this business's lead-generation configs (target profile, sources, scoring and schedule). Returns { configs }. |
POST /api/crm/lead-gen/configs |
crm:write |
Create a lead-generation config: who to look for, where to source them, how to score them and how often to run. An active config is scheduled for its first run. Returns 201 { config }. |
DELETE /api/crm/lead-gen/configs/{id} |
crm:write |
Delete a lead-generation config. Returns { success: true }. |
GET /api/crm/lead-gen/configs/{id} |
crm:read |
Get one lead-generation config by id. Returns { config }. |
PUT /api/crm/lead-gen/configs/{id} |
crm:write |
Update a lead-generation config. Setting is_active true schedules the next run from its frequency; false clears it. Returns { config }. |
GET /api/crm/lead-gen/runs |
crm:read |
List recent lead-generation runs, newest first. Optional query: config_id (one config's runs), limit (default 20). Returns { runs }. |
GET /api/crm/lead-gen/scores |
crm:read |
List AI-scored lead candidates found by lead generation. Optional query: config_id, status, min_score, limit (default 50), offset. Returns { scores }. |
GET /api/crm/lead-gen/settings |
crm:read |
Global defaults + this business's overrides, all masked. |
PUT /api/crm/lead-gen/settings |
crm:write |
Set or remove this business's lead-generation provider API keys (platform admins may send level "global" for the platform-wide defaults); an empty value removes a key, unlisted keys are kept. Returns { success: true }. |
POST /api/crm/lead-scoring |
crm:write |
Run live pipeline lead scoring now. With contact_id or deal_id that record is rescored first; with no body it scores whatever is already stale (up to 50). Returns { ok: true, ...counts }; 503 when scoring is unavailable. |
POST /api/crm/leads |
crm:write |
Add a sales lead to the CRM: finds or creates the company, contact and an open deal for the prospect and logs a drafted (unsent) outreach activity, deduped by source so sending the same prospect again updates instead of duplicating. For sales prospecting, not bookkeeping: record a paying client with POST /api/customers. |
GET /api/crm/lists |
crm:read |
List saved CRM lists (static lists of picked records, or smart lists defined by a filter), each with member_count. Optional query: entity_type (contact or deal). Returns { lists }. |
POST /api/crm/lists |
crm:write |
Create a CRM list of contacts or deals: static (optionally seeded with member_ids) or smart (requires a filter). Returns 201 { list }. |
DELETE /api/crm/lists/{id} |
crm:write |
Delete a CRM list (the contacts and deals in it are not touched). Returns { success: true } or 404 when no list has that id. |
GET /api/crm/lists/{id} |
crm:read |
Get one CRM list by id with its member count. Returns { list, memberCount } or 404. |
PATCH /api/crm/lists/{id} |
crm:write |
Rename a CRM list, change its description, or change a smart list's filter. Returns { list }. |
POST /api/crm/lists/{id}/enroll |
crm:write |
Enroll a list's members into an outreach sequence -- the second consumer of the list object (the first being the list-view UI). Contact lists only: enrollment keys on contact_id. |
DELETE /api/crm/lists/{id}/members |
crm:write |
Remove one record from a static CRM list. Required query: contact_id or deal_id. Returns { success: true }; 400 for smart lists. |
GET /api/crm/lists/{id}/members |
crm:read |
List the members of a CRM list (static rows, or a smart list's live filter results). Query: limit, offset. Returns { members, count, limit, offset }. |
POST /api/crm/lists/{id}/members |
crm:write |
Add records to a static list, either by explicit ids ("add selected") or by a filter ("add all matching"). Smart lists reject writes here -- their membership is defined by the saved filter, so edit the filter, not the rows. |
GET /api/crm/members |
crm:read |
The people who can own a CRM lead, person, deal or follow-up: the team (members, set as owner_id) and active AI employees (ai_owners, set as owner_employee_id). For roles and permissions use GET /api/team. Returns { members, ai_owners, current_user_id }. |
GET /api/crm/my-day |
crm:read |
A sales rep's worklist for today: open CRM follow-ups due by end of today, open deals sitting in a stage too long, and hot leads with nothing scheduled, ranked, each with a why_now line. Deal amounts here are pipeline estimates, not revenue or receivables (use GET /api/reports/profit-and-loss and GET /api/invoices). ?owner= me (default) | all | unassigned | a user id | comma-joined user ids. Returns { items, count }. |
POST /api/crm/next-action |
crm:read |
Draft the suggested next action for one deal or one contact (pass exactly one of deal_id or contact_id), using its recent activity, quotes, consent and deliverability. Returns the AI-drafted suggestion plus quoteConvertPrompt (deals only, else null). Read-only. |
GET /api/crm/outreach/drafts |
crm:read |
List drafted outreach activities (emails written but not yet sent) for the business. Returns { drafts }; send them with POST /api/crm/outreach/send-batch. |
GET /api/crm/outreach/log |
crm:read |
A CRM person's outreach touches (sequence emails and texts that went out, plus manually logged LinkedIn or other touches), newest first. Required ?contact_id=. Returns { entries }. |
POST /api/crm/outreach/log |
crm:write |
Log that a rep already reached out to a CRM person (contact_id, optional channel and status), mainly for LinkedIn. Sends nothing. Returns 201 { entry }. |
PATCH /api/crm/outreach/log/{id} |
crm:write |
Advance a manual touch's status (sent -> replied). No auto-send. Body: { status } validated by UpdateCrmManualTouchSchema. |
POST /api/crm/outreach/send-batch |
outreach:send |
Send many drafted outreach activities in one call. Each id runs through the same sendOutreachEmail service as the single-send route; per-item guards mean one failure never aborts the batch, so this always returns 200 with the aggregate. Body: { activityIds: string[], templateIds?: Record<activityId, templateId> } templateIds attributes a send to the saved template the rep composed it from (crm_email_sends.template_id -> per-template performance rollup); optional. |
GET /api/crm/pipelines |
crm:read |
List the business's sales pipelines (creates a default "Sales" pipeline with standard stages if none exist). Returns { pipelines }. |
POST /api/crm/pipelines |
crm:write |
Create a sales pipeline; it is seeded with the default stages (New, Talking, Quoted, Won, Lost). Returns 201 { pipeline }. |
DELETE /api/crm/pipelines/{id} |
crm:write |
Delete a pipeline. Returns { success: true }; 404 if not found. |
GET /api/crm/pipelines/{id} |
crm:read |
Get one pipeline by id. Returns { pipeline }; 404 if not found. |
PUT /api/crm/pipelines/{id} |
crm:write |
Update a pipeline's name, default flag, active flag or monthly revenue goal (send only the fields to change). Returns { pipeline }; 404 if not found. |
GET /api/crm/pipelines/{id}/stages |
crm:read |
List a pipeline's stages (steps) in board order. Returns { stages }. |
POST /api/crm/pipelines/{id}/stages |
crm:write |
Add a stage (step) to a pipeline. Returns 201 { stage }. |
DELETE /api/crm/pipelines/{id}/stages/{stageId} |
crm:write |
Delete an empty pipeline stage (step); 409 stage_not_empty while leads are in it. Returns { success: true }. |
PATCH /api/crm/pipelines/{id}/stages/{stageId} |
crm:write |
Rename a pipeline stage (step) or change its won/lost flags, win probability or requirements. Returns { stage }, or 404. |
PUT /api/crm/pipelines/{id}/stages/reorder |
crm:write |
Reorder a pipeline's stages: send every stage id in the new board order and they are renumbered at once. Returns { success: true }; 404 not_found for an unknown pipeline, 404 stage_not_found (missing_stage_ids, nothing reordered) for an id that is not one of its stages. |
GET /api/crm/pipelines/rollup |
crm:read |
Per-pipeline deal totals for the Pipelines index (/crm/pipelines). Returns one row per pipeline that has at least one deal; the caller joins it onto its own (rep-scoped) pipeline list and renders a miss as zeroes, so a board with no deals still shows up. |
GET /api/crm/qr-links/resolve |
crm:read |
Which lead a printed code points at right now (`deal_id` is null when the code is unassigned). Called server-side by landing sites such as the "I wish" flyer page with a crm:read key. 404 for a code never entered on any of this business's leads. |
POST /api/crm/qr-links/scan |
crm:write |
Record that a printed QR code was scanned (bumps its scan count) and return the lead it points at plus the example wishes to show on the landing page. Returns { link, wishes }; 404 if the code is unknown. |
POST /api/crm/query |
crm:read |
Answer a plain-language CRM question (e.g. "deals stalled more than 14 days", "won this month by rep") by parsing it into a structured query and running it. Read-only. Returns { parsed, summary, entity } plus either results and count, or an aggregation. |
GET /api/crm/quotas |
crm:read |
List sales quotas (targets set for reps or the team), optionally only the month or quarter containing period_anchor. A quota is a sales target, not income: for what the business earned use GET /api/reports/profit-and-loss. Returns { quotas }. |
POST /api/crm/quotas |
crm:write |
Set (create or update) a sales quota for a rep or the team over a month or quarter containing period_anchor (default today). Managers only (403 otherwise). Returns 201 { quota }. |
DELETE /api/crm/quotas/{id} |
crm:write |
Remove a quota. Manager-only (can_manage_users), matching the table's RLS write side. Through the service client, so the quota is confirmed to belong to ctx.businessId before deletion. |
GET /api/crm/quotas/attainment |
crm:read |
Sales quota attainment leaderboard for a month or quarter: each quota's closed-won deal value (actual), attainment %, shortfall and projection. Closed-won is CRM deal value, not booked revenue: to answer how much the business made, use GET /api/reports/profit-and-loss. Managers only (403 otherwise). Returns { period, as_of, leaderboard }. |
GET /api/crm/quotes |
crm:read |
List deal-level CRM quotes for the current business, newest first. Required ?deal_id scopes to a single deal (the deal-detail quote list); optional ?status filters. Each row carries a derived `effective_status`: a `sent` quote past its expiration surfaces as `expired` (never stored -- effectiveCrmQuoteStatus). Which quote system: CRM quotes are the sales-pipeline proposal attached to a deal (QT-0001); when the customer accepts and you need stock, an order and an invoice, convert it into a sales quote / sales order (/api/sales-quotes, Q-0001). |
POST /api/crm/quotes |
crm:write |
Create a draft quote and its lines on a deal. Lines may reference a catalog product or be ad-hoc/service lines (product_id omitted) -- ad-hoc is first-class here (a CRM commonly sells services with no SKU). The deal's contact/company are denormalized onto the quote as the recipient snapshot. Header + lines persist through the domain orchestration, which compensates a failed line insert. Which quote system: CRM quotes are the sales-pipeline proposal attached to a deal (QT-0001); when the customer accepts and you need stock, an order and an invoice, convert it into a sales quote / sales order (/api/sales-quotes, Q-0001). |
DELETE /api/crm/quotes/{id} |
crm:write |
Remove a quote and its lines (lines cascade). A converted quote is the record of a downstream accounting sales_quotes row and is not deletable. |
GET /api/crm/quotes/{id} |
crm:read |
Full quote: header, deal + contact + company, lines (with product), the derived `effective_status` (a `sent` quote past its expiration reads as `expired`), the immutable `versions` history (newest first), and -- while a revised quote sits in draft with prior sends -- `pending_send`: the pre-send heads-up of what a re-send would change vs what the buyer last saw ("about to send v3, total up 15%"). |
PATCH /api/crm/quotes/{id} |
crm:write |
Edit a draft quote's header and (optionally) fully replace its lines. Only a draft is editable -- once sent/accepted/declined/expired the offer is locked and edits belong on a new quote. When `lines` is present the set is replaced and totals recomputed; the line math stays in the domain (computeCrmQuoteTotals), the route only orchestrates the two repo writes. |
POST /api/crm/quotes/{id}/accept |
crm:write |
Record the buyer accepting a sent quote (sent -> accepted), stamping accepted_at. An expired quote (past its expiration_date) cannot be accepted -- re-send it with a new expiration first. An accepted quote can then be converted forward into an accounting sales_quotes row. |
POST /api/crm/quotes/{id}/convert |
crm:write |
Push an accepted CRM quote forward into an accounting sales_quotes row. The accounting customer is resolved (or created) from the deal's company/contact at this moment -- a pre-sale CRM quote never needs an accounting customer up front. Lines copy verbatim, ad-hoc/service lines included. Guards (accepted status, not already converted, has lines) live in the domain and throw CrmQuoteError -> 400. |
POST /api/crm/quotes/{id}/decline |
crm:write |
Record the buyer declining a sent quote (sent -> declined), stamping declined_at. Terminal for manual transitions -- supersede it with a new draft quote on the deal rather than reopening. |
POST /api/crm/quotes/{id}/revise |
crm:write |
Reopen an already-sent (or expired, or accepted-but-not-converted) quote back to `draft` so its lines can be edited and it can be re-sent as the next version. The prior send stays captured as an immutable crm_quote_versions snapshot; this only bumps version_number and flips status to draft (which re-enables the PATCH edit path -- canEditCrmQuote allows draft). The actual snapshot of the new version happens on the next /send. Guard: a quote already converted forward into an accounting sales_quotes row is locked and cannot be revised. |
POST /api/crm/quotes/{id}/send |
crm:write |
Mark a draft quote as sent to the buyer (draft -> sent), stamping sent_at. On send we also snapshot the quote as an immutable version (crm_quote_versions) so the buyer link's history is preserved, compute the plain-English change summary vs the previous sent version, and log a timeline note ("Sent quote QT-0007 v2 -- <summary>") so the rep and lead-scoring see the send + what changed. Only a `sent` quote is viewable via the public link. Returns the public share URL. |
POST /api/crm/quotes/draft-from-deal |
crm:write |
The AI-native draft path: from a deal's logged notes/activities and the product catalog, have Claude propose the quote's line items, then persist them as a draft crm_quote (ai_drafted = true) the rep opens and edits before sending. Body: { deal_id, expiration_date?, title? }. Returns 422 when there isn't enough deal context (or the model is unavailable) to draft any grounded lines -- the rep builds the quote manually via POST /api/crm/quotes in that case, rather than getting an empty junk draft. |
GET /api/crm/quotes/nudges |
crm:read |
The AI-native engagement feed: sent quotes the buyer opened but hasn't responded to for at least the threshold (findOpenedButSilentQuotes) -- the "they opened it 2 days ago, no reply yet" prompt that replaces the rep manually checking back. Optional ?threshold_days overrides the default nudge window. |
DELETE /api/crm/rep-pipelines |
crm:write |
Take a CRM pipeline away from a sales rep (managers only). Required ?pipeline_id= and ?user_id=. Returns { ok: true }; 403 for a non-manager. |
GET /api/crm/rep-pipelines |
crm:read |
Which sales reps may work which CRM pipelines. Pass ?pipeline_id= (who may work this pipeline) or ?user_id= (which pipelines this rep may work); one is required. A rep sees only their own assignments. Returns { assignments }. |
POST /api/crm/rep-pipelines |
crm:write |
Let a sales rep work a CRM pipeline (managers only). Returns 201 { assignment }; 403 for a non-manager; 400 if the user is not on this business's team. |
GET /api/crm/reports |
crm:read |
List saved CRM report tiles, each rendered against live data so it can be charted directly. Returns { reports }; a tile whose stored query no longer validates comes back with result: null and an error. |
POST /api/crm/reports |
crm:write |
Save an aggregate CRM query (from POST /api/crm/query) as a named report tile. Returns 201 { report } rendered against live data. |
DELETE /api/crm/reports/{id} |
crm:write |
Delete a saved report tile. Returns { ok: true }; 404 if not found. |
PUT /api/crm/reports/{id} |
crm:write |
Rename, re-scope (replace the query) or reposition a saved report tile; send only the fields to change. Returns { report } rendered against live data; 404 if not found. |
GET /api/crm/reps |
crm:read |
The sales team's reporting tree: every team member with their role, the manager they report to (manager_user_id) and their direct reports. Managers only (403 otherwise). Returns { reps, current_user_id }. |
PATCH /api/crm/reps/{userId} |
crm:write |
Update a member's rep role and/or manager. Covers the full rep CRUD: promote member -> salesman, demote back, and re-parent within the hierarchy. Either or both fields may be sent; each is validated with phase 1's schema, and a manager change is cycle-checked against the whole tree before it is persisted (the repo does not re-validate). Manager-only, via the service client scoped to ctx.businessId, the cycle check needs the whole edge set, which the select_self RLS policy would hide. Both the target and the proposed manager are confirmed members of this business before any write, since the service client is not RLS-bound. |
DELETE /api/crm/reps/{userId}/deactivate |
crm:write |
Reinstate a deactivated rep (clears deactivated_at). Records reassigned at offboarding are not moved back. Manager-only. Returns { ok: true }. |
GET /api/crm/reps/{userId}/deactivate |
crm:read |
Preview a rep's offboarding: counts of their open book (open deals, owned contacts, open activities) and the active reps who can receive it, each with their current open-deal load. Manager-only. Returns { book, targets }. |
POST /api/crm/reps/{userId}/deactivate |
crm:write |
Offboard a rep: reassign their open deals, owned contacts and open activities to one successor or spread them across the team, then mark the rep deactivated. Manager-only; the business owner cannot be deactivated. Returns { ok, reassigned } with counts. |
POST /api/crm/reps/invite |
crm:write |
Invite a sub-rep by email: creates a pending salesman invite (rolled up under manager_user_id, default the caller) and emails the accept link. Manager-only. Returns 201 { invite, message }. |
GET /api/crm/reps/rollup |
crm:read |
Deal and call totals per sales rep: their own (own, own_calls) and with everyone who reports to them (team, team_calls). Deal amounts are pipeline estimates, not revenue (use GET /api/reports/profit-and-loss). Managers only (403 otherwise). Returns { reps, current_user_id, base_currency, currency_mix, currency_notice }. |
GET /api/crm/rules |
crm:read |
List the business's CRM workflow automation rules, each with a plain-English summary. Returns { rules }. |
POST /api/crm/rules |
crm:write |
Create a workflow automation rule (object type, trigger, conditions, actions). To build one from plain English, call POST /api/crm/rules/draft first and send its draft here. Returns 201 { rule } with a summary. |
DELETE /api/crm/rules/{id} |
crm:write |
Delete a workflow rule. Returns { ok: true }; 404 if not found. |
GET /api/crm/rules/{id} |
crm:read |
Get one workflow rule with its plain-English summary. Returns { rule }; 404 if not found. |
PATCH /api/crm/rules/{id} |
crm:write |
Update a workflow rule. Send { active } alone to switch it on or off, or the full rule (same shape as POST /api/crm/rules) to replace it. Returns { rule } with a summary; 404 if not found. |
GET /api/crm/rules/{id}/executions |
crm:read |
The audit trail answering "why did this rule fire?", one row per record the rule acted on, with per-action results. |
POST /api/crm/rules/draft |
crm:write |
Compile a plain-English automation request into a rule draft using AI. Nothing is saved: returns { draft, summary, valid: true } ready for POST /api/crm/rules, or { draft, valid: false, issues } to correct first. |
GET /api/crm/rules/executions |
crm:read |
Recent runs of this business's CRM automation rules, newest first: which rule fired, on what record, and the outcome. Optional ?limit= 1-500 (default 100). Returns { executions }. |
POST /api/crm/rules/preview |
crm:read |
Dry-run: which current records would this rule act on? Read-only, nothing is executed or logged. Used by the builder so an operator sees the blast radius ("this would touch 14 deals right now") before saving a rule live. |
GET /api/crm/search |
crm:read |
Jump-to-record search across CRM contacts, companies, deals and quotes by name, email, phone or number (?q=, at least 2 chars; ?limit= per type, 1-20, default 5). It does not search accounting customers or vendors: find a client you invoice with GET /api/customers?search= and a vendor with GET /api/vendors?search=. Returns { query, results, nlSuggestion }; nlSuggestion true means the query reads like a question and should go to POST /api/crm/query instead. |
GET /api/crm/sequences |
crm:read |
List the business's outreach sequences (multi-step email cadences). Returns { sequences }. |
POST /api/crm/sequences |
crm:write |
Create an outreach sequence: a name, ordered steps and optional settings (exit conditions). To have AI write one from a goal, use POST /api/crm/sequences/draft. Returns 201 { sequence }. |
DELETE /api/crm/sequences/{id} |
crm:write |
Delete an outreach sequence. Returns { ok: true }; 404 if not found. |
GET /api/crm/sequences/{id} |
crm:read |
Get one outreach sequence with its steps and settings. Returns { sequence }; 404 if not found. |
PATCH /api/crm/sequences/{id} |
crm:write |
Update an outreach sequence's name, active flag, steps (replaced as a whole) or settings (merged into the existing settings). Returns { sequence }; 404 if not found. |
POST /api/crm/sequences/{id}/enroll |
crm:write |
Enroll contacts in an outreach sequence so its steps start sending. Returns the enrollment result; 400 if the sequence is not found or has no steps. |
GET /api/crm/sequences/{id}/enrollments |
crm:read |
List the contacts enrolled in an outreach sequence and their progress. Returns { enrollments }; 404 if the sequence is not found. |
POST /api/crm/sequences/{id}/redraft-step |
crm:write |
Have AI rewrite one underperforming email step of a sequence. Nothing is saved: returns { step_index, original, step } as a suggestion to apply via PATCH /api/crm/sequences/{id}. 404 if the sequence or step is not found; 400 for non-email steps. |
POST /api/crm/sequences/draft |
crm:write |
Have AI draft a full outreach sequence (steps, timing, copy variants) from a one-line goal. Nothing is saved: returns { draft } to review and save via POST /api/crm/sequences. |
DELETE /api/crm/sequences/enrollments/{id} |
crm:write |
Unenroll one contact from a sequence (status becomes exited, exit_reason manual; the row stays for history). Returns { ok: true }; 404 when no enrollment has this id. |
GET /api/crm/sequences/performance |
crm:read |
Email outreach sequence results: open, click and reply rates per sequence and per step, enrollment completion, and steps that underperform. Optional ?sequence_id=. Returns { sequences } (empty when the business has no sequences). |
DELETE /api/crm/suppressions |
crm:write |
Remove an email address from the do-not-contact list (?email=), re-permitting outreach to it. Returns { ok: true }. |
GET /api/crm/suppressions |
crm:read |
The do-not-contact / suppression list admin surface. GET, list suppressed addresses for the business (admin view). POST, manually mark an address do-not-contact. DELETE, re-permit an address suppressed in error. The automatic paths (unsubscribe-link click, AI-detected opt-out) write the same table directly; these routes are the human-driven ones. |
POST /api/crm/suppressions |
crm:write |
Mark an email address do-not-contact for this business (reason defaults to manual_dnc). Returns 201 { suppression }. |
GET /api/crm/tasks |
crm:read |
CRM follow-up tasks (calls, emails, to-dos logged on people and deals), soonest due first. Not project tasks: those are GET /api/pm/tasks. ?owner= all (default) | me | unassigned | a user id | comma-joined user ids; ?completed=true lists done ones (default open); ?limit= 1-500 (default 50), ?offset=. Returns { tasks, count, limit, offset }. |
GET /api/crm/templates |
crm:read |
List saved email templates with their send performance (?include_archived=true|false; true includes archived ones). Returns { templates }. |
POST /api/crm/templates |
crm:write |
Save a reusable email template (name, optional category, subject, body with {{first_name}}-style merge vars). Returns 201 { template }; 409 if a template with that name exists. |
DELETE /api/crm/templates/{id} |
crm:write |
Delete an email template. Returns { ok: true }; 404 if not found. |
PATCH /api/crm/templates/{id} |
crm:write |
Update an email template's name, category, subject, body or archived flag (send only fields to change). Returns { template }; 404 if not found, 409 if the new name is taken. |
POST /api/crm/templates/draft |
crm:write |
Have AI turn a pasted email or plain-language description into a reusable template (name, category, merge-var subject and body). Nothing is saved: returns { draft } to review and save via POST /api/crm/templates. |
GET /api/crm/territory-rules |
crm:read |
List territory rules that auto-assign new leads to reps or teams by region, employee band or account tier. Returns { rules }. |
POST /api/crm/territory-rules |
crm:write |
Create a territory rule that auto-assigns new leads matching its region, employee band or account tier to a rep or team. Manager-only (can_post) via RLS. Returns 201 { rule }. |
DELETE /api/crm/territory-rules/{id} |
crm:write |
Delete a territory rule. Manager-only via RLS. Returns { success: true }; 404 when no rule has this id. |
PUT /api/crm/territory-rules/{id} |
crm:write |
Update a territory rule (send only the fields to change). Manager-only via RLS. Returns { rule }; 404 when no rule has this id. |
GET /api/crm/webhooks |
crm:read |
List the business's outbound CRM webhook subscriptions. Returns { subscriptions }. |
POST /api/crm/webhooks |
crm:write |
Subscribe a public HTTPS URL to CRM events; a signing secret is generated. Returns 201 { subscription }; 400 if the URL is not publicly reachable. |
DELETE /api/crm/webhooks/{id} |
crm:write |
Delete a webhook subscription. Returns { ok: true }; 404 if not found. |
PATCH /api/crm/webhooks/{id} |
crm:write |
Update a webhook subscription's name, target URL, event types or active flag (send only fields to change). Returns { subscription }; 404 if not found. |
GET /api/crm/webhooks/{id}/deliveries |
crm:read |
List recent delivery attempts for a webhook subscription (status, response, timing). Returns { deliveries }; 404 if the subscription is not found. |
POST /api/crm/webhooks/draft |
crm:write |
Have AI turn a plain-language request into a webhook subscription draft (name, events). Nothing is saved: returns { draft } to review and save via POST /api/crm/webhooks. |
GET /api/crm/wish-examples/default |
crm:read |
The business-wide default example wishes (crm_wish_examples, scope 'default'): what a flyer shows when the lead has no examples of its own and its company has no business type, and what an unassigned code shows. |
PUT /api/crm/wish-examples/default |
crm:write |
Replace the business-wide default example wishes shown on QR landing pages when a lead has none of its own. Returns { examples }. |
/api/customer-returns 3
| Operation | Scope | What it does |
|---|---|---|
GET /api/customer-returns |
sales:read |
Customer returns (RMAs), newest first, optionally filtered by status (open | completed | cancelled), sales_order_id or warehouse_id. An unknown status or parameter is a 400. Create one with POST /api/sales-orders/{id}/return. |
GET /api/customer-returns/{id} |
sales:read |
A customer return with its lines and dispositions, restock movements, refund ledger entry (metadata.refund holds method and amount) and the invoices the goods were billed on. Returns { return, movements, journalEntry, invoices }. |
POST /api/customer-returns/{id}/refund |
sales:write |
Record the refund for a completed customer return that has none yet (the return response's next_actions points here). method cash|card|check posts Dr Revenue / Dr Sales Tax Payable / Cr the bank or cash account in from_account_id (else the cash role account) for amount (default: the full refundable amount; less keeps a restocking fee); method none records that nothing is paid back. Returns { refund }; 409 refund_already_recorded / sale_not_paid / account_roles_missing, 400 refund_exceeds_refundable / store_credit_unsupported / invalid_from_account. |
/api/customers 14
| Operation | Scope | What it does |
|---|---|---|
GET /api/customers |
ledger:read |
Invoicing customers of the business, by name; search narrows to customers whose name, email or phone contains it. filters echoes what was applied, and any other query parameter is a 400. Invoices and sales orders take these ids (a CRM contact id is not one). |
POST /api/customers |
ledger:write |
Create an invoicing customer. Refuses with 409 code possible_duplicate (existing: at most 5 matches, name-and-email first, with existing_total and a more_existing lookup when there are more; nothing is changed) when a customer with the same name or email exists; reuse that id, or resend with confirm_duplicate: true if it really is a different customer. default_terms_days sets the customer's payment terms (invoices default their due date from it); unknown keys such as payment_terms_days are a 400 naming the closest field. |
DELETE /api/customers/{id} |
ledger:write |
Delete a customer. Only allowed if the customer has no non-draft invoices. |
GET /api/customers/{id} |
ledger:read |
Fetch a single customer. |
PATCH /api/customers/{id} |
ledger:write |
Update a customer: send only the fields to change (name, email, phone, address, notes, price_list_id, credit_limit, credit_hold, tax_exempt, tax_exempt_reason, default_terms_days). An unknown key is a 400. Returns { customer }, plus warnings [{ code: possible_duplicate, existing }] when the new name or email is already on another customer (the change is still saved; ask the owner whether they are the same customer); 404 when the id is not this business's customer. |
GET /api/customers/{id}/credit-status |
ledger:read |
A customer's credit position: exposure (open invoice balance plus the uninvoiced value of confirmed sales orders), creditLimit, availableCredit, manualHold and payment-behavior risk. Use it before extending more credit to a customer; for what a customer owes ('how much does this customer owe me') use GET /api/reports/ar-aging?customer_id= (totals.total, by age bucket), and for their open invoices GET /api/invoices?customer_id=. customerCredit is money they paid that no invoice used yet (GET /api/customers/{id}/credits), owed back or usable on their next invoice. ?orderTotal=NNN previews the hold decision a sales order of that size would get (the gate itself runs when a sales order is submitted). |
GET /api/customers/{id}/credits |
ledger:read |
A customer's credits: money they paid that no invoice needed yet (a retainer, deposit, prepayment or advance paid before the work was invoiced, a duplicate payment, an overpayment), held in the Customer Deposits liability. Returns open_credit (what they can still use or get back) and credits [{ id, amount, applied_amount, refunded_amount, open_amount, received_at, source_invoice_id, journal_entry_id, history, refunds }]. refunds lists each cash refund { entry_id (the refundId of its void), amount, date, voided, void_entry_id, voided_at, void_reason }; refunded_amount counts only the live ones. In history a refunded record of a voided refund carries voided: true and void_entry_id (and a refund_voided record follows), so sum refunds where voided is false, or read refunded_amount, never every kind refunded record. Use it with POST /api/invoices/{id}/payments { from_customer_credit: true }. |
POST /api/customers/{id}/credits |
ledger:write |
Record money a customer paid that no invoice needs yet as their credit: a retainer, deposit, prepayment or advance paid before you invoice them (no source_invoice_id), or an invoice paid twice or overpaid. Not income when it lands; it becomes revenue as invoices are paid from it. posts debit the bank (deposit_account_id; required when the business has more than one cash or bank account: 400 deposit_account_required with accounts (at most 5, the likeliest first with why: where this customer's last payment or credit was deposited, else where most of the business's recent invoice payments went), accounts_total and more_accounts when there are more, ask_owner and a resend next_action per account) and credit Customer Deposits (customer_deposits role, usually 2650) on payment_date, and tracks it per customer. With existing_entry_id it registers money a posted entry already put in Customer Deposits (e.g. drafted by hand earlier) without posting again. Later, POST /api/invoices/{id}/payments { amount, from_customer_credit: true } uses it on an invoice (debit Customer Deposits, credit revenue), or POST /api/customers/{id}/credits/{creditId}/refund sends it back (debit Customer Deposits, credit the bank). bank_transaction_id names the bank-feed deposit (money in) this is, e.g. a needs_clarification row the owner says is a client paying ahead: deposit_account_id and payment_date default to the row, amount must equal it (400 bank_amount_mismatch), 409 transaction_already_matched when the row is already in the books (a row still linked to the entry of a voided payment or refund, with no bank row showing that money going back, is taken over instead), and the row is linked to the entry in the same call (bank_transaction { id, status matched, matched_entry_id }). The response lists the customer's open_invoices (id, invoice_number, due_date, balance_due, status: sent, or draft for one created but never marked sent) with a next_action per invoice to pay it from this credit (from_customer_credit). Not a credit memo: a credit memo is a price concession and moves no money. 409 closed_period / reconciled_period unless confirmed. |
POST /api/customers/{id}/credits/{creditId}/refund |
ledger:write |
Send (part of) a customer's credit back to them: posts debit Customer Deposits, credit the bank (from_account_id; deposit_account_id, account_id and payment_account_id are accepted as the same field; default the cash role (it must be named when the business has more than one cash or bank account: 400 deposit_account_required lists at most 5 (accounts_total and more_accounts, GET /api/accounts?subtype=bank, for the rest), the account the credit was deposited to first, with a next_action per account; the amount is checked before the account)) on refund_date, and lowers the credit's open_amount. amount defaults to the whole open amount. Revenue is untouched: the credit was never revenue. bank_transaction_id names the bank-feed payment (money out) this refund is (POST /api/bank-feed/process outcome customer_credit_refund): from_account_id, refund_date and amount default to the row (a row dated a day after today, as the import allows, refunds on that date), amount must equal it (400 bank_amount_mismatch), 409 transaction_already_matched when the row is already in the books (a row still linked to the entry of a voided payment or refund, with no bank row showing that money going back, is taken over instead), and the row is linked to the refund entry in the same call (bank_transaction { id, status matched, matched_entry_id }). Returns refund_id (the refund's entry, also posted_entry_id). A refund recorded in error, on the wrong date or from the wrong bank is undone with POST /api/customers/{id}/credits/{creditId}/refunds/{refund_id}/void, never a ledger reverse. 400 refund_before_credit when refund_date is before the credit's received_at (earliest_refund_date). 400 exceeds_open_credit when this credit has less open (next_actions names another of the customer's credits that covers it). 409 bank_row_other_customer when the bank_transaction_id row's text names another customer (named_customer; rows_for_customer lists the open rows that name this one, any amount, amount_matches true first, a next_action for each that matches), unless confirm_other_customer once the owner says the row is this customer's. 409 closed_period / reconciled_period unless confirmed. A part refund that leaves credit open lists the customer's open invoices (customer_open_invoices) and next_actions lead with paying them from what is left (POST /api/invoices/{id}/payments { amount, from_customer_credit: true }) once the owner confirms. |
POST /api/customers/{id}/credits/{creditId}/refunds/{refundId}/void |
ledger:write |
Undo a customer credit refund recorded in error, on the wrong date or from the wrong bank account: { reason, confirmClosedPeriod?, confirmReconciledPeriod? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. refundId is the refund's entry: posted_entry_id from POST /api/customers/{id}/credits/{creditId}/refund, or entry_id of a history record with kind refunded on GET /api/customers/{id}/credits. Posts the mirror of the refund entry on the refund's own date (void_entry_id; a posted ledger reversal of it is reused instead), puts the amount back on the credit (open_amount, history kind refund_voided) and sends the bank-feed row matched to the refund back to unmatched (bank_rows_released). Then record the refund again on the right day with POST /api/customers/{id}/credits/{creditId}/refund. Never undo a refund with POST /api/ledger/{id}/reverse (409 document_entry). 400 invalid_id, 404 credit_not_found, refund_not_found, 409 already_voided, status_changed, reversal_draft_pending, reversal_registered_as_credit (the hand reversal was registered as a customer credit: void that credit with keep_entry: true first), closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true. |
POST /api/customers/{id}/credits/{creditId}/void |
ledger:write |
Undo a customer credit recorded in error (a deposit or prepayment that never arrived, or entered twice): { reason, confirmClosedPeriod?, confirmReconciledPeriod?, keep_entry? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. keep_entry: true, for a credit registered on an entry it did not post (existing_entry_id, e.g. a carried-over credit from the open invoices import), voids the credit only (released: true), posting nothing and leaving that entry as it is. Posts the mirror of the credit's entry on its own date (void_entry_id, a posted REVERSAL of reversed_entry_id: debit Customer Deposits, credit the bank), sends the bank-feed row matched to that entry back to unmatched (bank_rows_released), and keeps the credit with voided_at, void_reason and open_amount 0. Only a credit nothing was taken from: 409 credit_in_use when part was used on an invoice (next_actions void each invoice payment that used it with POST /api/invoices/{id}/payments/{paymentId}/void, which gives the amount back to the credit; then void the credit) or refunded (money that really went back stays). 400 invalid_id, 404 credit_not_found, 409 already_voided, shared_entry (the credit's entry records more than this credit: correct it in the ledger, or, when registered_on_entry, resend with keep_entry: true), 400 keep_entry_not_allowed, reversal_draft_pending, closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true. Money that really came in and went back to the customer is POST /api/customers/{id}/credits/{creditId}/refund, not a void. |
GET /api/customers/{id}/statement/pdf |
ledger:read |
Generate and return a customer statement of account: all of the customer's open invoices with an aging summary and closing balance due, as a sendable PDF. Reuses the AR aging per-customer computation as its data source. |
POST /api/customers/import |
ledger:write |
Import a customer list when migrating (switching) from QuickBooks, Xero or a spreadsheet: content (the exported CSV; title lines above the header are fine, columns are auto-detected and column_mapping overrides them; terms come from a Terms text column (QuickBooks: Net 15) or Xero DueDateSalesDay / DueDateSalesTerm into default_terms_days, a month-based Xero term is left out with a row warning; a phone that is not a phone number is left out with a row warning, like POST /api/customers) or rows [{ name, email, phone, address, terms }] (an account number column goes into notes). Send preview: true first: each row comes back would_create, exists (same name as a customer of the business: existing_id, matched_on; never overwritten), possible_match (same email as a customer or an earlier row but a different name, e.g. a shared billing inbox: existing_name; ask the owner via ask_owner and resend with create_anyway_rows [row numbers] for the different businesses), duplicate_in_file or invalid, with the detected column_mapping. The commit creates only the would_create rows and returns created [{ row, id, name }] and not_imported, so importing the same file again is safe; create_anyway_warnings lists row numbers that are not possible_match rows (ignored). Next: POST /api/invoices/import-open for the open invoices. |
POST /api/customers/sync-contacts |
ledger:write |
Bulk-import contacts from a mobile device. Skips contacts that already exist (matched by phone or email). |
/api/dashboard 3
| Operation | Scope | What it does |
|---|---|---|
DELETE /api/dashboard/widgets |
settings:write |
Reset the caller's dashboard layout to the product default. Returns the default { widgets }. |
GET /api/dashboard/widgets |
settings:read |
The caller's dashboard layout. Returns { widgets, defaults, customized, available } where available lists every widget id they may turn on. |
PUT /api/dashboard/widgets |
settings:write |
Replace the caller's dashboard layout with an ordered list of widget ids or { id, size } entries. Unrecognized entries are dropped. Returns the saved { widgets, customized }. |
/api/decorator 6
| Operation | Scope | What it does |
|---|---|---|
GET /api/decorator/previews/{path} |
decorator:read |
Serve the PNG proof of a decoration session by its stored path (business_id/session_id.png, from the session's metadata.preview_path). Revalidates on every request; append the session's updated_at as a cache-buster. |
GET /api/decorator/sessions |
decorator:read |
List this business's decoration sessions, optionally filtered by ?status= and ?order_id=. Returns { sessions }. |
POST /api/decorator/sessions |
decorator:write |
Create a decoration session for a parent SKU, anchored to an order (order_id + order_type) or a cart (cart_id), optionally pre-filled with variant lines. Returns 201 { session }. |
GET /api/decorator/sessions/{id} |
decorator:read |
Get one decoration session with its lines. Returns { session }, or 404. |
PATCH /api/decorator/sessions/{id} |
decorator:write |
Update a decoration session's status or metadata and/or fully replace its line set (omit lines to keep them; an empty array clears them). Returns { session }. |
POST /api/decorator/sessions/{id}/preview |
decorator:write |
Store the rendered proof of a decoration session: the canvas flattened to a PNG (product blank + artwork), so an order line can SHOW the decoration rather than merely assert one exists. The bytes go to the private `decoration-previews` bucket at `<business_id>/<session_id>.png` and the session records `metadata.preview_path`. One preview per session, overwritten on re-save -- which is why the path is NOT content-addressed and callers must version the URL (see the GET route's cache header). PNG only, verified by magic bytes rather than trusting the data-URL prefix. |
/api/dmv 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/dmv/remittances |
ledger:read |
Point-in-time reconciliation of the 2350 'DMV Fees Payable' liability plus the batch remittance history. "Outstanding to remit" is read off the live 2350 balance (collected credits minus remitted debits), not a per-order counter. |
POST /api/dmv/remittances |
ledger:write |
Record a batch remittance of pass-through DMV fees sent to the state and post the deterministic clearing entry Dr 2350 DMV Fees Payable / Cr Cash. |
/api/documents 10
| Operation | Scope | What it does |
|---|---|---|
GET /api/documents |
documents:read |
List documents with optional filters (paginated) |
DELETE /api/documents/{id} |
documents:write |
Delete a document and its storage file |
GET /api/documents/{id} |
documents:read |
One document's metadata with a short-lived signed_url to download the file. 404 when the id is not this business's document. |
PATCH /api/documents/{id} |
documents:write |
Update a document's metadata (title, document_type, description, metadata, expiry) and its links to a location, contact, company or deal. document_type is one of tax_return, contract, license, certification, permit, insurance, receipt, invoice, w2, 1099, bank_statement, other (anything else is a 400). Linked ids must belong to the business. Returns the document. |
GET /api/documents/{id}/crm-suggestions |
documents:read |
Propose which CRM contact/company/deal this document belongs to, so it can be attached with a one-tap confirm. Seam for the quote-generation and inbound-email-capture tickets, which call this with a freshly created document id. |
GET /api/documents/{id}/share |
|
List all share links for a document. Owner-only: never key-addressable. |
POST /api/documents/{id}/share |
|
Create a public share link for a document, optionally password-protected, expiring or download-capped. Returns { share } with its share_url. User sessions only. |
DELETE /api/documents/{id}/share/{shareId} |
|
Revoke a share link. Owner-only: never key-addressable. |
GET /api/documents/expiring |
documents:read |
Documents whose expires_at falls between now and ?days from now (default 30), soonest first. |
POST /api/documents/upload |
documents:write |
Upload a document (PDF or image, 50 MB max) to the document vault. Images and PDFs (up to 20 MB, e.g. bank statements) are read into extracted_text and AI-classified into document_type (tax_return, contract, license, certification, permit, insurance, receipt, invoice, w2, 1099, bank_statement, other); PATCH /api/documents/{id} corrects the type. extraction { status: read | no_text | reader_unavailable | file_too_large | not_read, message } says whether the file's text was read: reader_unavailable means the reader failed (the file is fine and stored; a bank statement still goes to POST /api/bank-feed/import/json { document_id, dry_run: true }), and classification.unavailable true means document_type is not a real classification. JSON { filename, content_type, data_base64, location_id?, contact_id?, company_id?, deal_id? } or multipart/form-data with a file field and the same optional link fields. |
/api/drafts 6
| Operation | Scope | What it does |
|---|---|---|
GET /api/drafts |
ledger:read |
Captures and drafts waiting for the owner's review, newest first. ?status=unposted (default) | capture | draft | posted | all. Page with ?limit and ?offset (next_offset is set while more remain). Without limit the response also carries the chart of accounts (accounts); a paged call leaves it out (read it once from GET /api/accounts). ?fields=compact returns just id, date, description, status and lines. Read one with GET /api/drafts/{id}. Post one with POST /api/drafts/{id}/post only when the owner approves. A draft that replaces a posted entry whose reversal is not posted yet carries warning { code: original_not_reversed, message, reversal, next_actions }: posting it alone counts the transaction twice. A draft made from bank rows that are all ignored (a double import whose draft was never discarded) carries bank_row_status: ignored, ignored_bank_rows, bank_row_message and bank_row_next_actions (DELETE the draft, or unignore the row): don't post it; POST /api/drafts/post selections leave it out. |
DELETE /api/drafts/{id} |
ledger:write |
Delete a draft (or capture) and its lines; posted entries can't be deleted (reverse them). A bank row created from or matched to it goes back to 'unmatched': released_bank_transactions lists them with next_actions to draft or match them again. Half of a correction pair (a draft reversal, or a draft with replaces_entry_id) whose other half is still a draft is 409 correction_pair_member { original_entry_id, counterpart_id, next_actions } and nothing is deleted: ?delete_pair=true deletes both (pair_deleted), ?confirm_unpaired=true only this one. |
GET /api/drafts/{id} |
ledger:read |
One capture, draft or posted entry by id, with its lines (account code, name, type), the same row GET /api/drafts lists. Use it to re-read a draft after PATCH /api/drafts/{id} or processing. ?fields=compact returns just id, status, transaction_date, description, note, counterparty_id and lines (code, name, debit, credit). 400 for a malformed id, 404 when the business has no such entry. A replacement draft whose original's reversal is not posted carries warning { code: original_not_reversed, next_actions } (posting it 409s until that is fixed or confirmed). A draft whose bank rows are all ignored carries bank_row_status: ignored, ignored_bank_rows, bank_row_message and bank_row_next_actions (DELETE it, or unignore the row); posting it answers 409 bank_row_ignored. |
PATCH /api/drafts/{id} |
ledger:write |
Edit a draft or capture (description, date, counterparty label, vendor_id link for a vendor paid, crm_contact_id, location, lines naming account_id or account_code). New lines replace the old ones; a capture with lines becomes a draft. Moving an AI draft off the account the AI picked stamps ai_corrected_at / ai_corrected_by and sets ai_correction_note ('Corrected to 5993 Farrier Supplies on 2026-11-09 (the AI had suggested 5000 Cost of Goods Sold, which the AI explanation is about).'; null again when the lines go back to the AI's pick); ai_explanation and ai_suggested_* keep the AI's own record, so read ai_correction_note first when quoting why a draft is on its account (GET /api/drafts and GET /api/drafts/{id} carry it); when the draft came from a bank row and its accounts change (off the AI's pick, off the uncategorized catch-all, or split across several lines, where the largest category opposite the bank line counts), next_actions offers POST /api/merchant-rules (or PATCH the rule that books this merchant elsewhere) so next month's rows categorize themselves; none when a rule already books it there. A card processor payout's fees and gross sales: use POST /api/bank-feed/transactions/{id}/processor-payout { gross, fees, sales_tax, non_taxable_sales } instead of hand-built lines. A line for sales that carried no sales tax (repair labor, services) sends taxable: false so GET /api/reports/sales-tax counts it as exempt, not taxable sales. On a draft from a bank feed row, new lines whose bank or card line no longer equals the row (a mistyped split: loan payment 400 + 70 against a 480 row) are saved but return warning { code: bank_amount_mismatch, message, mismatches [{ bank_transaction_id, account_id, row_amount, line_amount, difference, entry_line_ids }], next_actions } first in next_actions; POST /api/drafts/{id}/post refuses it with 409 until the bank line equals the row. A transaction_date after the business's today returns warning { code: future_date, transaction_date, today, message, next_actions } (usually a typo such as the wrong year); posting it answers 409 future_date until resent with confirmFutureDate: true. New lines that credit an income account on a draft whose bank row the feed reads as the owner's own money (owner_money: "FROM ... PERSONAL CHK", "OWNER CONTRIBUTION") return warning { code: owner_money_to_income, message, ask_owner, bank_transaction_id, income_accounts, next_actions } and no merchant rule; posting it answers 409 owner_money_income until resent with confirmOwnerMoneyIncome: true. Its mirror: new lines that debit an expense account (cost of goods sold included) on a draft whose bank row reads as the owner taking money ("TRANSFER TO ... PERSONAL CHK", "OWNER DRAW") return warning { code: owner_money_to_expense, message, ask_owner, bank_transaction_id, expense_accounts, next_actions } and no merchant rule; posting it answers 409 owner_money_expense until resent with confirmOwnerMoneyExpense: true. |
POST /api/drafts/{id}/post |
ledger:write |
Post a draft to the ledger (several the owner approved at once: POST /api/drafts/post { ids }). An entry that puts an opening receivable / payable against owner equity returns next_actions to POST /api/invoices/import-open / /api/bills/import-open with its opening_balance_account_id; one that opens a bank or credit card account against Opening Balance Equity returns next_actions to import that account's first statement after the entry date (POST /api/bank-feed/import/json { chart_account_id, dry_run: true }), or, when rows from after the opening are already imported on that account, POST /api/bank-feed/process to sort the ones still open (GET /api/bank-feed/transactions?chart_account_id= when all are sorted) instead of importing again, and reconcile it from beginning_balance = the opening (POST /api/bank-reconciliation). 409 already_posted (entry_id, posted_at, next_actions GET /api/ledger/{id}) when it was already posted, e.g. a retry after a dropped connection: the first call succeeded; 404 only when no such entry exists. 409 future_date { transaction_date, today, next_actions } when it is dated after the business's today (usually a typo such as the wrong year; correct it with PATCH /api/drafts/{id}, or resend with confirmFutureDate: true only if the owner confirms that future day; a draft from a bank feed row may be dated up to the row's date, one day ahead). 409 owner_money_income { bank_transaction_id, income_accounts, ask_owner, next_actions } when the draft came from a bank row that reads as the owner's own money ("FROM ... PERSONAL CHK", "OWNER CONTRIBUTION") and credits an income account: move that line to the owner's equity account with PATCH /api/drafts/{id}, or resend with confirmOwnerMoneyIncome: true only if the owner confirms a client paid it. 409 owner_money_expense { bank_transaction_id, expense_accounts, ask_owner, next_actions } is its mirror: a bank row that reads as the owner taking money ("TRANSFER TO ... PERSONAL CHK", "OWNER DRAW") debited to an expense account: move that line to the owner's draws account, or resend with confirmOwnerMoneyExpense: true only if the owner confirms it repaid them for a business cost they paid personally. 409 closed_period when it is dated in a closed month (resend with confirmClosedPeriod: true if the owner agrees); 409 reconciled_period when a line hits an account whose last completed bank reconciliation has a statement date on or after the entry date (resend with confirmReconciledPeriod: true, or reopen that reconciliation; with posted_after_statement: true the draft's bank row came from a statement that begins at that reconciliation's ending balance, so the bank posted it after that statement was cut: post it with confirmReconciledPeriod: true on its bank date and it clears on the next reconciliation, no reopen or redate; same_day_as_statement: true when it is dated on that statement date: next_actions then reconcile through the statement it was imported from (POST /api/bank-reconciliation with that statement's ending date and balance) when that statement runs past the date, else extend the completed reconciliation (reopen, PATCH, complete). The confirmed post repeats that step in the 200 as reconciled_period_follow_up and next_actions). Not for half of a correction pair (POST /api/ledger/{id}/recategorize: the reversal and the replacement) whose lines on that account cancel with the other half dated on the same statement: it posts and returns reconciled_period_netted (with next_actions to post the other half while it is a draft). Not for a reversal of an entry that reconciliation left uncleared (an outstanding item such as a second opening): it moves the books back to the statement, so it posts and returns reconciled_period_uncleared_original { message, original_entry_id, reconciliations }. Posting the second half of a correction (a reversal, or an entry with replaces_entry_id) moves the original's bank feed rows to the replacement: bank_rows_relinked { original_entry_id, replacement_entry_id, bank_transaction_ids }, and the invoice and bill payments, customer credits and refunds that posted the original to it: records_relinked { original_entry_id, replacement_entry_id, records: [{ table, id }] }. When the original was cleared in a completed bank reconciliation and the reversal and replacement cancel on that account on or before its statement date, both are recorded as cleared in it, so the next reconciliation does not list them: reconciliation_cleared { message, reconciliations: [{ reconciliation_id, chart_account_id, entry_line_ids }] }. 409 duplicate_opening { duplicates [{ entry_id, status, date, account_id, net, same_date }], next_actions } when the draft has a line on Opening Balance Equity and another posted entry, on any date, has the same amount on the same account against it: posting would open the account at twice the balance. When only some lines repeat it (duplicate_account_ids), next_actions PATCH /api/drafts/{id} with the lines minus those, Opening Balance Equity taking up the difference, then post; when every line repeats it, discard this draft (and post the first if it is a draft); resend with confirmDuplicateOpening: true only if the owner confirms two separate openings. A reversal (entry_type REVERSAL) of an opening returns no opening next_actions. 409 replacement_exists when the draft replaces an entry another posted entry already replaces (resend with confirmAdditionalReplacement: true only for a deliberate split). 409 original_not_reversed when the draft replaces an entry (replaces_entry_id) that has no posted reversal: posting it would count the transaction twice, so next_actions post the draft reversal first or draft one (POST /api/ledger/{original}/reverse { same_date: true }); resend with confirmOriginalNotReversed: true only if the owner keeps both. 409 bank_amount_mismatch { mismatches [{ bank_transaction_id, account_id, row_amount, line_amount, difference, entry_line_ids }], next_actions } when the draft came from (or is matched to) a bank feed row and its lines on that row's bank or card account do not move it by the row's amount (a mistyped split): fix the lines with PATCH /api/drafts/{id} (next_actions carries the body when the draft has one line on each side), then post; resend with confirmBankAmountMismatch: true only if the owner confirms the bank row is wrong. 409 bank_row_ignored { ignored_bank_rows, confirm_field, next_actions } when every bank row the draft was made from is ignored (a double import whose leftover draft was never discarded): discard it (DELETE /api/drafts/{id}) or unignore the row if that was a mistake; resend with confirmBankRowIgnored: true only if the owner wants the entry posted anyway. An entry linked to a 1099 vendor (vendor_id) with no usable tax id that pays them from a bank or cash account (not by card or a payment app) returns warnings [{ code: missing_tax_id, vendor_id, ask_owner, next_actions }]: quote the ask_owner to get the W-9, then PATCH /api/vendors/{id} { tax_id }. |
POST /api/drafts/post |
ledger:write |
Post several drafts the owner approved in one call: ids (up to 100), or every draft with a line on a bank or card account (chart_account_id) or made from a feed's bank rows (feed_id), optionally dated through a YYYY-MM-DD. Each draft gets every check of POST /api/drafts/{id}/post; the confirm flags (confirmClosedPeriod, confirmReconciledPeriod, ...) apply to all of them, so send one only when the owner agreed to it for the whole batch. Reversals post first, then by date. Partial success: 200 with posted, needs_confirmation, errors and remaining counts and results [{ id, status: posted | needs_confirmation | error, http_status, code, message, confirm_field, next_actions, ... }] (each row carries the single route's response fields; an id that is not a draft is an error with code already_posted, not_a_draft or not_found and the entry's entry_status). confirmations groups the held-back ids by code, and next_actions resends them with the flag, to call only after the owner agrees. A selection with more than 100 drafts posts the first 100 and returns remaining with the same call in next_actions. When every draft on a bank or card account is posted, ready_to_reconcile lists it (with statement_ending_date and statement_ending_balance from its latest imported statement when the import carried them, and on the account's first reconciliation that statement's statement_start_date and beginning_balance) and next_actions start its reconciliation (POST /api/bank-reconciliation); an account with no imported statement or bank connection (a savings account whose only entry is a transfer in) is listed in needs_statement instead (optional: true; only needed when the owner wants that account reconciled, not for an expenses-only owner), with a next_action to import its statement, since there is nothing to reconcile it against yet. dry_run: true posts nothing and returns would_post [{ id, transaction_date, description, amount }] and a next_action that posts exactly those ids: check a chart_account_id or feed_id selection with it first, since it also picks up drafts the owner may not expect. A draft whose bank rows are all ignored (a double import whose draft was never discarded) is left out of a chart_account_id or feed_id selection and of every dry_run and listed in held_back [{ id, code: bank_row_ignored, ignored_bank_rows, message, next_actions: DELETE the draft, or unignore the row }]; sent by id it comes back needs_confirmation bank_row_ignored (confirmBankRowIgnored posts it anyway). Post only what the owner approved in this conversation. |
/api/email 1
| Operation | Scope | What it does |
|---|---|---|
POST /api/email/send |
email:send |
Send one email as the business through its own email setup (Gmail, SMTP or Resend in Settings > Integrations), from its configured mailbox. Body { to (one address or up to 10), subject, text and/or html, reply_to?, from_name? }. Recipients at reserved test domains (*.example, *.test, *.invalid, *.localhost, example.com/.net/.org) are not sent to and come back in skipped with reason reserved_test_address. Returns { sent, from, to (delivered), skipped, provider, message_id }; 409 email_not_configured, 502 send_failed. |
/api/entries 1
| Operation | Scope | What it does |
|---|---|---|
POST /api/entries/draft |
ledger:write |
Create a draft journal entry (shows in Drafts, not posted to the ledger until a person approves it via POST /api/drafts/{id}/post). This is the endpoint for "record this expense / income as a draft entry", and the one an agent uses after reading the chart of accounts (GET /api/accounts) and deciding the entry itself. Natural-language capture is POST /api/v1/quickcapture. Body: { description?, note?, transaction_date?: "YYYY-MM-DD", counterparty_id? (free-text label), crm_contact_id? (CRM contact link), vendor_id? (vendor paid, for 1099s), lines: [{ account_id or account_code, debit?, credit?, description? }], receipt?, replaces_entry_id? (the posted entry this corrects; its bank rows move here), confirm_additional_replacement?, opening? } (strict: an unknown key is a 400 naming the closest field, e.g. date -> transaction_date; opening: true without transaction_date is 400 opening_date_required, ticket 829b761d) (at least 2 lines; total debits must equal total credits; a line has a debit or a credit, not both). receipt is { filename, content_type, data_base64 } for the receipt image or PDF (10 MB max), stored privately and OCR'd. Example $6 office supplies paid by card: lines [{ account_id: <Office Supplies expense>, debit: 6 }, { account_id: <Credit Card or Checking>, credit: 6 }]. Returns 201 { data: { ...entry, lines } } (null ai_* columns left out), plus warning { code: "closed_period", month, books_closed_through, message } when the date is in a closed month, or { code: "reconciled_period", reconciliations, message, next_actions } when a line hits an account reconciled through the date (posting either will 409 until confirmed). warnings lists every one; warning is the most severe (original_not_reversed, then future_date, then closed_period, then reconciled_period). future_date: the date is after the business's today (posting 409s until confirmFutureDate, ticket f5b5a3e2). With replaces_entry_id: 409 replacement_exists when the original already has a live replacement, and warning original_not_reversed when it has no posted reversal yet (tickets e7f5c170, 524ec76b, 2def2502; posting 409s with the same warning). |
/api/feeds 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/feeds |
bankfeed:read |
The business's bank and card feeds (each statement import lands in one), newest first. Each feed has chart_account { id, code, name, type } (the account it posts through; null until mapped with PATCH /api/feeds/{id}), first_transaction_date and last_transaction_date, transaction_count and transactions_by_status (unmatched, draft_created, matched, ignored, needs_clarification; a row stays draft_created after its draft is posted, so drafts_posted counts those done and drafts_awaiting_posting the rest (including a row whose posted entry was reversed with no posted replacement), and waiting_count = unmatched + needs_clarification + drafts_awaiting_posting is what still needs the owner), and last_synced_at (the last import). A feed is named after the first file imported into it, so tell feeds apart by account and date range. Pass a feed's id as feed_id to POST /api/bank-feed/import/json to add the next statement to it. |
POST /api/feeds |
bankfeed:write |
Create an empty feed. Rarely needed: POST /api/bank-feed/import/json creates the feed on the first import. |
DELETE /api/feeds/{id} |
bankfeed:write |
Hide a feed (soft delete: is_active false). Its imported rows and any drafts or entries made from them stay. 400 for a malformed id, 404 for a feed outside the business. |
GET /api/feeds/{id} |
bankfeed:read |
One bank or card feed: feed (with chart_account { id, code, name, type }, first_transaction_date, last_transaction_date, transactions_by_status, plus drafts_posted, drafts_awaiting_posting and waiting_count: a draft_created row whose draft is posted is done, not waiting, unless that entry was reversed with no posted replacement), transaction_count, and imports (every statement imported into it, newest first: original_filename, row_count, duplicate_count, imported_at), whatever the source. 400 for a malformed id, 404 for a feed outside the business. |
PATCH /api/feeds/{id} |
bankfeed:write |
Rename a feed, set the chart account it posts through (chart_account_id: an asset or liability account, 400 not_a_bank_account otherwise; null unmaps), or set a CSV feed's column mapping. Unknown fields are a 400. Moving a feed whose rows are already drafted or matched to a different account is a 409 feed_has_booked_rows with ask_owner: those rows keep the old account and only new drafts use the new one; resend with confirm_account_change: true once the owner agrees. |
POST /api/feeds/{id}/sync |
bankfeed:write |
Trigger Plaid sync for a Plaid-type feed. |
GET /api/feeds/{id}/transactions |
bankfeed:read |
One feed's bank rows, newest first, with count (all matching rows). Filters: status (unmatched | draft_created | matched | ignored | needs_clarification), from_date and to_date (YYYY-MM-DD; from and to also work), pending, search, limit (1-1000, default 50), offset. An unknown parameter or bad value is a 400. Same rows as GET /api/bank-feed/transactions?feed_id={id}. |
POST /api/feeds/{id}/upload-csv |
bankfeed:write |
Upload and parse a CSV file for a CSV-type feed. If the feed has no csv_column_mapping yet, returns the CSV headers so the client can show a mapping UI. The client should PATCH the feed with the mapping and then re-upload. |
/api/fulfillment 6
| Operation | Scope | What it does |
|---|---|---|
GET /api/fulfillment/tasks |
fulfillment:read |
Warehouse pick/pack/ship tasks, newest first, filtered by warehouse_id, status, wave_label and limit. Operations, not bookkeeping: COGS posts from the sales order at shipment. |
POST /api/fulfillment/tasks |
fulfillment:write |
Plan a wave: group the open sales orders' unfulfilled lines into one fulfillment task per warehouse, ordered along the pick path by location sort_order. Each line's suggested pick face is the location holding on-hand for that SKU with the lowest sort_order (null = warehouse-level bucket). Reservations are set (+quantity_to_pick) at creation; no stock moves until the `shipped` step. Returns one task per warehouse in the wave. |
GET /api/fulfillment/tasks/{id} |
fulfillment:read |
A fulfillment task header plus its lines (with product / suggested-location / sales-order context), for the pick and pack screens. |
PATCH /api/fulfillment/tasks/{id} |
fulfillment:write |
Advance the pick/pack/ship status machine (and record pack/ship attributes). Picking and packing only move status and record no stock movement. The transition to `shipped` runs shipFulfillmentTask: it releases the task's reservations and posts one `shipment` movement + fulfillment writeback per sales order through the single inventory write path, so deriveSalesOrderStatus still drives order status. `cancelled` releases the reservations without shipping. |
GET /api/fulfillment/tasks/{id}/packing-slip |
fulfillment:read |
Printable packing slip (PDF) for a fulfillment task, the pack screen's document. Lists each line with its suggested pick face, source order, and picked/planned quantity, plus carrier/tracking/package summary. |
POST /api/fulfillment/tasks/{id}/pick |
fulfillment:write |
Record a pick against one task line (the scan-to-pick sink; Phase 4 wires the camera scan into this same endpoint). Records quantity_picked only -- no stock movement posts until the `shipped` step. The first pick auto-starts a pending task (pending -> picking). Picked quantity is capped at the line's planned quantity_to_pick. |
/api/geo 1
| Operation | Scope | What it does |
|---|---|---|
GET /api/geo/geocode |
crm:read |
Geocode an address (?address=) to coordinates, with the same provider the route planner uses. result is null when nothing matches; 503 configured:false when no maps provider is set. |
/api/intake 5
| Operation | Scope | What it does |
|---|---|---|
GET /api/intake |
intake:read |
List intake items for the business, filterable by ?status, source_type, search, page and per_page. Returns { items, count }. |
POST /api/intake |
intake:write |
Capture a new intake item (raw text for the AI to turn into suggested actions). With analyze: true, runs the analysis inline. Returns 201 { item }. Requires can_capture. |
GET /api/intake/{id} |
intake:read |
item detail with extractions + action suggestions. |
POST /api/intake/{id}/analyze |
intake:write |
classify + summarize + extract + suggest (can_capture). |
POST /api/intake/{id}/approve |
intake:write |
execute approved suggestions (can_post). Idempotent: already-executed suggestions are skipped, so retries create no duplicate records. |
/api/integrations 4
| Operation | Scope | What it does |
|---|---|---|
GET /api/integrations/etsy/connect |
|
Start connecting the business's Etsy shop: stores a PKCE verifier and redirects the signed-in user to Etsy's OAuth consent page (browser flow, user session only). Redirects to /settings with etsy_error when Etsy is not configured. |
POST /api/integrations/etsy/listings |
etsy:write |
Publish a new listing to the business's connected Etsy shop (draft by default) and upload its images and digital files best-effort. Returns { ok, listing_id, ... } or an error with Etsy's status. |
PATCH /api/integrations/etsy/listings/{listing_id} |
etsy:write |
Update fields on an existing Etsy listing (title, description, price, quantity, state, tags, materials, shipping profile, shop section); set state to inactive to pull it. Returns { ok, listing_id, state }. |
POST /api/integrations/etsy/listings/{listing_id}/assets |
etsy:write |
Attach one image or one digital file to an existing Etsy listing (the retry path for a failed create-time upload). Returns { ok, listing_id, asset_type, asset_id }. |
/api/inventory 9
| Operation | Scope | What it does |
|---|---|---|
POST /api/inventory/builds |
inventory:write |
Run a kitting build: turn stocked components into a finished good. Resolves the product's active bill of materials server-side, then posts one atomic set of N+1 inventory legs (one assembly_consume per component at its WAC + one assembly_produce for the finished good at computed cost) through the single postInventoryMovement write path. Shortfall is warn-not-block (returned, not rejected). Body: { product_id, warehouse_id, quantity, additional_cost?, notes? }; additional_cost overrides the BOM's flat labor/overhead add. |
POST /api/inventory/builds/preview |
inventory:read |
Cost a build and check component availability WITHOUT posting. Resolves the product's active BOM, reads each component's WAC + on-hand at the warehouse, and returns the computed finished-good cost plus any component shortfall so the operator sees cost and shortage before confirming the build. Read-only. |
GET /api/inventory/levels |
inventory:read |
Stock on hand per product and warehouse, each row with available = on-hand minus reserved and its average_cost. Reading posts nothing, but stock and the ledger move together only through the stock calls: receiving, shipping and returns on purchase and sales orders, and POST /api/inventory/movements (whose adjustments and counts post a ledger entry unless opening_stock: true). A journal entry or draft line on an inventory account does not change these quantities. Optional product_id and warehouse_id (UUIDs), below_reorder=true|false, group_by=location|variant, sellable=true|false. A malformed id, another value or an unknown parameter is a 400. For the money value of stock use the Inventory account on GET /api/reports/trial-balance. |
GET /api/inventory/movements |
inventory:read |
The stock movement history (receipts, shipments, adjustments, counts, transfers, returns, builds), newest first. Optional product_id, warehouse_id, reference_id (UUIDs), movement_type (a stored type: a transfer is transfer_out + transfer_in), reference_type, limit 1-500. A bad value or unknown parameter is a 400. |
POST /api/inventory/movements |
inventory:write |
Change stock by hand: a sale, an adjustment (quantity is a signed change), a count (quantity is what was counted; stock is set to it) or a transfer between warehouses (from_warehouse_id, to_warehouse_id). A sale is goods sold without a sales order, at a market or through a card reader (Square, Stripe): { movement_type: "sale", product_id, quantity (units sold, positive), date (the sale or payout date, default today), reference_type: "bank_transaction" and reference_id (the payout's bank row) }, warehouse_id defaulting to the one holding the most; it POSTS Dr the product's cost of goods sold / Cr its inventory at the average cost, described 'Cost of <n> <product> sold (payout <bank text> on <date>)', the same entry a sales-order shipment posts; ledger names debit_account, credit_account and amount; 400 insufficient_stock above what is on hand, 400 not_stocked for an untracked product. Stock is checked as of date too: a sale (or an adjustment taking stock out) dated before goods now in stock were received, so that stock would sit below zero on that day or a later one, is a 409 stock_not_on_hand_on_date with on_hand_on_date, the later movements, earliest_date (the first day enough was on hand) and nothing recorded; resend with that date, or with confirm_negative_stock: true only when the owner says the goods really were there and the receipt was dated late. The same product already sold on the same reference_id (a retry, a second pass over the payout's next_actions) is a 409 possible_duplicate with the existing movements and nothing recorded; resend with confirm_duplicate: true only for more units sold on top. A sale recorded in error or with the wrong quantity is undone with POST /api/inventory/movements/{id}/void (units back in stock, mirror entry to cost of goods sold), never an adjustment or a ledger reverse; a warning sales_exceed_payout flags goods at list price well above the payout. processor-payout and create-draft offer it (stock_sold) when money in goes to a stocked product's income account. Never use an adjustment for goods sold: it books shrinkage. An adjustment or count without warehouse_id lands in the warehouse already holding the product, else the default, else the only active one (GET /api/catalog/warehouses); with several and none of those it is a 400 warehouse_required listing them. An adjustment or count that changes stock value POSTS a ledger entry dated date (default today): found stock debits the product's inventory account and credits the inventory adjustment account (the product's cost of goods sold account when there is none), lost stock the reverse; the response's ledger names the accounts. Stock on hand at a switch from QuickBooks, Xero or a spreadsheet, or at books start, is already in the opening balance: send it with opening_stock: true and unit_cost (the old system's average cost) so nothing posts, and check tie_out, which compares the stock value with the inventory account's posted balance. Once the product has any other movement in that warehouse (a receipt, sale, adjustment, transfer), opening_stock is a 409 opening_stock_after_movements naming a count instead (confirm_after_movements: true only when the owner says the opening stock itself was wrong). A date in a closed month is a 409 closed_period. Transfers post nothing. |
POST /api/inventory/movements/{id}/void |
inventory:write |
Undo a stock sale (POST /api/inventory/movements { movement_type: "sale" }) recorded in error: twice, the wrong quantity or the wrong product. Body { reason?, date? (default the sale entry's date) }. Puts the units back in stock at the cost they left at (a customer_return movement with metadata.voids_movement_id) and POSTS the mirror entry Dr inventory / Cr cost of goods sold for the same amount, so stock and the inventory account still agree; the sale keeps its row with metadata.voided_by. Then record the right quantity with a new sale. Never undo a sale with POST /api/ledger/{id}/reverse (409 document_entry: stock would stay out) or an adjustment (it books shrinkage). 400 invalid_id, not_a_sale; 404 movement_not_found; 409 already_voided (void_movement_id), closed_period (send date in an open month), sale_entry_unknown. |
POST /api/inventory/putaway |
warehouse:write |
Move stock between two bins in the SAME warehouse (putaway / bin move). Either side may be the NULL warehouse-level bucket, so this also assigns unallocated stock to a bin. Expands into a paired transfer_out + transfer_in through the domain posting service (single write path), no new movement type. |
POST /api/inventory/putaway-suggestions |
inventory:read |
Suggest bins to put away a product in a warehouse (bins already holding the SKU, recently used bins, then empty or active bins). Read-only; returns { suggestions: [{ locationId, label, reason, onHand }] }. |
GET /api/inventory/qc-hold |
inventory:read |
Received stock waiting in a QC or receiving hold location (on hand, not yet sellable), each with the purchase order it came in on. Optional warehouse_id (UUID); a malformed one or an unknown parameter is a 400. No ledger effect. |
/api/invoices 18
| Operation | Scope | What it does |
|---|---|---|
GET /api/invoices |
ledger:read |
Invoices, newest first, with the customer and location. Each row leads with balance_due, what the customer still owes (0 once paid or voided); amount_billed is the total less any applied deposit and does not drop when paid (never read it as owed). Rows also carry amount_paid, amount_refunded and net_received (paid less refunds made with POST /api/invoices/{id}/refund or by a customer return of its goods), credit_memo_applied (credit memos applied to it), sales_order_deposit_applied (a sales-order deposit netted against it), customer_credit_applied (the part of amount_paid paid from the customer's open credit: a retainer, deposit or overpayment, not new money; 'how much of the retainer went to this invoice' reads this one), payment_status (unpaid | partially_paid | paid | partially_refunded | refunded | void), overdue and days_past_due (0 unless overdue), first_sent_at (the first send; sent_at is the latest), last_reminded_at and reminder_count (payment reminders sent with POST /api/invoices/{id}/remind), so 'who owes me, how late and have we nudged them' is one call: sum balance_due (unpaid and partially_paid are what is still owed; a voided invoice is void and owes 0). A partly paid invoice keeps status sent. Filters: status (draft | sent | paid | cancelled), payment_status (open = unpaid or partially_paid: 'list open invoices'), customer_id (404 customer_not_found when it is not a customer of this business; a CRM contact id says so and gives linked_customer_id), overdue (true | false), location_id, search (invoice number, customer name or email; how to find an invoice by its number; q is the same filter); filters echoes what was applied, and any other parameter or value is a 400. |
POST /api/invoices |
ledger:write |
Create an invoice (a draft; POST /api/invoices/{id}/send issues it). A line can name an item from GET /api/catalog/products with product_id: its description, unit_price (customer price list, else sell_price) and account_id (the product's income_account_id) fill in when the line leaves them out, and anything sent explicitly wins; 400 product_not_found or unit_price_required. A line names its income account by account_id or account_code (400 unknown_account_code, account_mismatch, or invalid_line_account when it is not an income account); with neither it posts to the product's income account, else the revenue role account. line_accounts in the response shows the account each line posts to at payment (posts_to line_account, revenue_role, revenue_default when no account holds the revenue role yet and the lowest-code income account stands in, sales_discounts, nets_revenue or opening_receivable); check it before sending (taxable true on a line does not mean tax was charged: tax is added only when sales tax is tracked at a saved rate). sales_tax_notice { note, ask_owner, next_actions } appears when a taxable line carries no tax because sales_tax_handling is ignore or no rate is saved: ask the owner before sending (only a note, without ask_owner, when the owner saved ignore after adding every taxable item on it). When the customer has open credit (a retainer, deposit or overpayment), customer_open_credit, open_credit_note and next_actions say how much and how to use it before they are billed in full: a payment from_customer_credit: true when the credit covers the invoice, else (a draft can only be paid in full) a send dry run with apply_customer_credit and customer_credit_amount. Otherwise next_actions are the send dry run (on a draft) and POST /api/invoices/{id}/payments for the total (add bank_transaction_id when the payment is already a bank-feed row). To bill an estimate use POST /api/sales-quotes/{id}/invoice, which copies its lines and links the two. invoice_number keeps an existing number, else the next in sequence. due_date defaults to issue_date plus terms_days (net days), else the customer's default_terms_days; with neither the invoice is due on receipt (due_date = issue_date) and the response carries due_date_note so you can confirm with the owner. Any key not listed in the body is a 400 with a did-you-mean hint (due_days: did you mean terms_days?). issue_date defaults to today in the business's time zone and may be after today; the response then carries issue_date_note and warning future_date (warnings [{ code, issue_date, today, message, next_actions }]: usually a typo such as the wrong year; on a draft next_actions offers PATCH /api/invoices/{id} with issue_date today), and next_actions offers no payment, because payment dates (POST /api/invoices/{id}/payments payment_date) must be on or before today in the business's time zone (scheduling timezone), not UTC's today. For an invoice still unpaid at the switch date when moving from QuickBooks, Xero or a spreadsheet, pass opening_balance: true (and opening_balance_account_id if the chart has several receivable accounts): it opens as sent without notifying anyone, and its payments clear the opening A/R instead of crediting revenue again. 409 invoice_number_taken, 409 opening_balance_account_required, 409 opening_receivable_short when the opening receivable account's posted balance (less other open opening_balance invoices) is less than the invoice total, or opening_balance_not_posted when only a draft opening entry covers it (draft_entry_ids; the owner posts it first), unless confirmOpeningReceivableShort: true. A generated invoice number skips numbers already used (an explicit invoice_number from an import does not bump the sequence); a 500 carries code (invoice_number_conflict, invoice_create_failed, invoice_lines_failed, invoice_number_failed) and retryable: true, meaning nothing was created and the same request can be resent without double-billing. When the customer has open credit memos (credit the business already gave them, unused), the response adds customer_open_credit_memos { total, credits [{ id, number, credit_date, remaining }] }, open_credit_memo_note and ask_owner, and next_actions lead with POST /api/invoices/{id}/send { apply_credit_memos: true, dry_run: true } (a draft takes a memo only as it is sent). |
DELETE /api/invoices/{id} |
ledger:write |
Delete a draft invoice. |
GET /api/invoices/{id} |
ledger:read |
One invoice (?fields=id,status,balance_due returns only those invoice keys, plus ask_owner and next_actions; line_accounts and the credit memo fields only when named) with its customer, lines, payments and line_accounts (where each line posts at payment). Like a GET /api/invoices row it leads with balance_due (what the customer still owes; 0 once paid or voided), then amount_billed (total less applied deposit; it does not drop when paid, never what is owed), amount_paid, amount_refunded, net_received (paid less refunds), credit_memo_applied (credit memos applied to it), sales_order_deposit_applied (a sales-order deposit netted against it), customer_credit_applied (the part of amount_paid paid from the customer's open credit: a retainer, deposit or overpayment received earlier, not new money; 'how much of the retainer went to this invoice' reads this one; the three never overlap), payment_status (unpaid | partially_paid | paid | partially_refunded | refunded | void) overdue and days_past_due; first_sent_at (the first send; sent_at is the latest), last_reminded_at, reminder_count, reminders [{ at, channel, to, delivered, days_past_due }] (payment reminders from POST /api/invoices/{id}/remind; the platform sends none on its own) and deliveries (every send, resend, mark_sent and reminder, oldest first: { at, kind, channel, to, delivered }; empty for sends before 2026-11-05); refunds lists what refunded money on it: credit memos (source credit_memo) and customer returns of its goods (source customer_return, with customer_return_id, amount, method, refund_date). A voided invoice (status cancelled) reads payment_status void, balance_due 0 and amount_billed 0. When a sent invoice still has balance_due and its customer holds open credit (a retainer or overpayment in Customer Deposits), invoice.customer_open_credit says how much and next_actions offers POST /api/invoices/{id}/payments { amount, from_customer_credit: true }: a client who paid net of their credit is not still owing. When the customer has open credit memos (credit the business already gave them, unused), the response adds customer_open_credit_memos { total, credits [{ id, number, credit_date, remaining }] }, open_credit_memo_note and ask_owner, and next_actions lead with the send with apply_credit_memos (draft) or POST /api/credit-memos/{id}/apply { invoice_id, amount } (sent). These are separate from customer_open_credit (a deposit or retainer). |
PATCH /api/invoices/{id} |
ledger:write |
Edit a draft invoice (400 invoice_not_draft once it is sent, 400 invoice_cancelled once voided); send only the fields to change. lines replaces every line and recomputes the totals and sales tax. A line can name an item from GET /api/catalog/products with product_id: its description, unit_price (customer price list, else sell_price) and account_id (the product's income_account_id) fill in when the line leaves them out, and anything sent explicitly wins; 400 product_not_found or unit_price_required. A line names its income account by account_id or account_code (400 unknown_account_code, account_mismatch, or invalid_line_account when it is not an income account). line_accounts in the response shows the account each line posts to at payment (posts_to line_account, revenue_role, revenue_default when no account holds the revenue role yet and the lowest-code income account stands in, sales_discounts, nets_revenue or opening_receivable). terms_days or issue_date sent without due_date moves due_date to issue_date plus the terms (sent, else stored; an invoice due on receipt with no terms stays due on its new issue date) and the response carries due_date_note; send due_date to pick the date yourself. Any key not listed in the body is a 400 with a did-you-mean hint (due_days: did you mean terms_days?), and a body with nothing to change is a 400 no_changes. |
GET /api/invoices/{id}/link |
ledger:read |
Get the public URL for an invoice (for clipboard copy). |
POST /api/invoices/{id}/mark-paid |
ledger:write |
Record a manual payment (check, cash, Zelle, ...) and post the cash-basis ledger entry. Same as POST /api/invoices/{id}/payments except amount is optional: omit it to pay the whole remaining balance; a smaller amount records a partial payment and the invoice stays sent until the balance reaches zero. payment_date (YYYY-MM-DD, default today) is the ledger date (before the invoice's issue_date: 409 payment_before_invoice unless confirmPaymentBeforeInvoice); deposit_account_id picks the bank/cash asset account debited (default the cash role (it must be named when the business has more than one cash or bank account: 400 deposit_account_required lists them, the likeliest first with why and a resend for each of those; with more than 5 accounts and nothing pointing anywhere, no_likely_account: true and no resend)). Returns amount, balance_due, fully_paid, transaction_date, deposit_account. An amount over the balance is 409 overpayment unless excess_to_customer_credit: true, which posts the one deposit as one entry and holds the excess as the customer's credit (customer_credit in the response). 409 closed_period for a date in a closed month unless confirmClosedPeriod; 409 reconciled_period for a date on or before the statement date of the deposit account's last completed bank reconciliation unless confirmReconciledPeriod (reconciliations and next_actions to reopen it); on an opening_balance invoice, 409 opening_receivable_short when the payment would take the opening receivable account below zero, unless confirmOpeningReceivableShort; 409 account_roles_missing if cash/revenue accounts are not set; 409 invoice_already_paid on a paid invoice (when reference, amount and payment_date (default today) equal a payment already recorded on it and no bank_transaction_id is sent, same_payment_recorded: true and matching_payment: a repeated call, nothing to do, with no credit offer; otherwise total, amount_paid, payments, ask_owner, next_actions: GET its payments, or draft a double payment to Customer Deposits (refunded from there, not through the invoice refund)). Every resend next_action names this route with the original body plus the confirm flag. |
GET /api/invoices/{id}/payments |
ledger:read |
Payments recorded against an invoice, each with deposit_account { id, code, name } (the bank or cash account it went into; for a payment from customer credit, the Customer Deposits account it was paid from), plus balance_due (what is still owed: amount_billed less payments) and amount_billed (total less applied deposit, not what is owed). |
POST /api/invoices/{id}/payments |
ledger:write |
Record a customer payment against a sent invoice (400 invalid_id when id is not the full UUID): { amount, payment_method, reference?, payment_date?, deposit_account_id?, bank_transaction_id?, from_customer_credit?, excess_to_customer_credit?, confirmClosedPeriod?, confirm_other_customer? }. Same as POST /api/invoices/{id}/mark-paid except amount is required. payment_date (YYYY-MM-DD, default today, or the bank row's date with bank_transaction_id; never after today, except the date of the bank_transaction_id row, which the import allows up to a day ahead) is the ledger date, so a late-recorded check posts in the month it arrived; deposit_account_id picks the bank/cash asset account debited (default the cash role (it must be named when the business has more than one cash or bank account: 400 deposit_account_required lists at most 5, the likeliest first with why (where this customer's last payment was deposited, then the last payment on another invoice carried over on the same opening receivable account, then the account a clear majority of recent invoice payments went to), with a resend next_action for each of those; when nothing points anywhere, no_likely_account: true and no resend, only ask_owner and accounts[] (first by code) plus more_accounts; a paid or cancelled invoice, or an amount over balance_due, gets its 409 first and is never asked for the account)). from_customer_credit: true pays from the customer's credit instead (money they paid earlier and that was recorded with POST /api/customers/{id}/credits): the entry debits Customer Deposits, payment_method is optional, and 409 customer_credit_short (available_credit) when their open credit is less than amount. A use of credit posts no earlier than the day the credit covering it was received (received_at, credits taken oldest first) and the invoice's issue_date: without payment_date it defaults to the latest of today and those (a credit dated tomorrow, as bank rows import a day ahead, is used on its own date; so the next_actions that use a credit carry payment_date: the deposit's date, or the later of the credit's date and the invoice's issue_date), and an earlier payment_date is 409 credit_before_received or credit_before_invoice_date with earliest_payment_date, credit_received_at, issue_date and a next_action resending on earliest_payment_date (never inside a closed month: a credit carried over in the opening balance is used from the first open day, with books_closed_through); a credit use dated in a closed month is 409 closed_period with first_open_date and a resend on it, since using a credit moves no money. A cash payment dated before the invoice's issue_date is 409 payment_before_invoice (issue_date, earliest_payment_date, ask_owner: mistyped date or a deposit paid ahead? next_actions: resend on the issue_date (not offered with bank_transaction_id), record it as the customer's credit with POST /api/customers/{id}/credits, or resend with confirmPaymentBeforeInvoice: true once the owner says it belongs here). A payment that clears the invoice on a date before a credit use already on it posts with warning later_credit_use (later_credit_uses) and next_actions to void that credit use and apply it again on this payment's date; without bank_transaction_id the payment's own date is questioned first (ask_owner, and next_actions[0] voids this payment to record it again on its real date). A draft invoice can be paid in full (from cash or from_customer_credit): it is marked paid without being sent, like a cash sale; a partial payment on a draft is 409 partial_on_draft (with from_customer_credit its next_actions send the invoice with apply_customer_credit and customer_credit_amount, which applies that part of the credit first and shows the balance due on the customer's copy). Partial payments on a sent invoice are allowed (the invoice stays sent with a lower balance_due; when a deposit naming the invoice or its customer for exactly the balance left is open on the bank feed, bank_rows_waiting lists it and next_actions lead with the payment on that row) and each posts its share of revenue, sales tax, DMV fees and applied deposit; the payment that clears the balance marks the invoice paid. The bill payment names paid_at and from_account_id (and account_id, payment_account_id) are accepted as aliases; payment_method takes the same values as bill payments (cash, check, ach, bank_transfer, wire, card, zelle, venmo, cashapp, apple_pay, other); ach and wire are stored as bank_transfer, so responses and next_action bodies say bank_transfer. Returns transaction_date (payment_date is the same date under the request's name) and deposit_account. bank_transaction_id names the bank-feed deposit this payment is (POST /api/bank-feed/process outcome invoice_payment, or invoices[] on GET /api/bank-feed/transactions/{id}/matches): deposit_account_id defaults to that row's bank account (400 deposit_account_mismatch for another) and payment_date to its date; a payment_date other than the row's date is 409 payment_date_differs_from_bank_row (bank_row_date, days_apart, ask_owner, next_actions[0] resends on the row's date; for a check received before it was deposited, a second next_action keeps the date with confirm_date_differs_from_bank_row: true once the owner says so), amount must equal the row (400 bank_amount_mismatch), 409 transaction_already_matched when the row is already in the books (a row still linked to the entry of a voided payment or refund, with no bank row showing that money going back, is taken over instead), 409 bank_row_other_customer when the row's text names a customer other than the invoice's (named_customer; rows_for_customer are the open deposits that name the invoice's customer, any amount, amount_matches true first, a next_action for each that matches; named_customer_invoices are the named customer's open invoices of exactly the row's amount, each a next_action recording the row there) unless confirm_other_customer once the owner says the row is this one; after posting, the row is linked to the payment entry (bank_transaction { id, status matched, matched_entry_id }), and a draft the row had made for itself is named in next_actions (DELETE it). An amount over the balance (the customer paid too much) is 409 overpayment with balance_due, excess, ask_owner and next_actions: resend with excess_to_customer_credit: true, which records the one deposit as one posted entry (debit the bank amount, credit revenue the balance, credit Customer Deposits the excess, so the bank row matches it), returns received and customer_credit { id, amount }, and the credit pays their next invoice with from_customer_credit or is refunded from POST /api/customers/{id}/credits/{creditId}/refund (when money out to that customer of at most the credit is already on the bank feed, customer_credit.refund_rows_waiting lists it and next_actions[0] is the refund { amount, payment_method, bank_transaction_id } on the row, then only what is left pays their open invoices); 409 closed_period for a date in a closed month unless confirmClosedPeriod (ask_owner, and next_actions: this request with confirmClosedPeriod: true, or POST /api/periods/reopen for each month from the one the books are closed through (books_closed_through, the latest closed month) back to this one, latest first); 409 reconciled_period for a date on or before the statement date of the deposit account's last completed bank reconciliation unless confirmReconciledPeriod (reconciliations and next_actions to reopen it; with posted_after_statement: true the bank_transaction_id row came from a statement that begins at that reconciliation's ending balance, so the only next_action is the same request with confirmReconciledPeriod: true, keeping the date, plus same_day_as_statement and the reopen, PATCH, complete extension when it is dated on the statement date) (on an opening_balance invoice dated before it was carried over it adds before_books_start, books_start_date and ask_owner: the payment probably belongs in the old system); on an opening_balance invoice, 409 opening_receivable_short when the payment would take the opening receivable account below zero, unless confirmOpeningReceivableShort; 409 invoice_already_paid on a paid invoice (when reference, amount and payment_date (default today) equal a payment already recorded on it and no bank_transaction_id is sent, same_payment_recorded: true and matching_payment: a repeated call, nothing to do, with no credit offer; otherwise with from_customer_credit: true it only says nothing was used from the credit, with customer_open_credit, other_open_invoices to use it on instead and no ask_owner or new-credit call, since no money came in; otherwise total, amount_paid, payments, other_open_invoices: the customer's other open invoices the money may be for, ask_owner, next_actions: GET its payments, the same payment on each other open invoice, or record a double payment as the customer's credit with POST /api/customers/{id}/credits, carrying bank_transaction_id when the request had one (used on a later invoice with from_customer_credit, or refunded from there, not through the invoice refund or a credit memo)). |
POST /api/invoices/{id}/payments/{paymentId}/void |
ledger:write |
Undo an invoice payment recorded in error or a check that bounced: { reason, confirmClosedPeriod?, confirmReconciledPeriod? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. Posts the mirror of the payment's entry on the payment's own date (void_entry_id, a posted REVERSAL linked to reversed_entry_id), puts the amount back on the invoice's balance_due (a paid invoice goes back to sent and into A/R aging), sends the bank-feed row matched to the payment back to unmatched (bank_rows_released), and keeps the payment with voided_at and void_reason next to an offset row (negative amount, voids_payment_id) in invoice_payments. A bank fee for the bounced check is its own expense (POST /api/entries/draft). 400 invalid_id (a non-UUID id or paymentId; paymentId answers with the call that lists the real ids), 404 payment_not_found, 409 already_voided, is_void_record, invoice_cancelled, card_payment (online checkout: refund it with POST /api/invoices/{id}/refund), paid_from_customer_credit (only for an old credit payment the credits' history does not record), excess_to_customer_credit, has_refunds (more refunded than would be left paid), reversal_draft_pending, closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true (only when the payment was cleared in that reconciliation: a payment never cleared there was not on that statement, so its void leaves it unchanged and needs no confirm). A payment from customer credit (from_customer_credit: true) gives its amount back to the credits it used (customer_credits_restored [{ credit_id, amount, open_amount }]); its entry Dr Customer Deposits / Cr revenue is mirrored. Money that really came in and went back to the customer is a refund, not a void. |
GET /api/invoices/{id}/pdf |
ledger:read |
The invoice as a PDF file (application/pdf, filename {invoice_number}.pdf), binary rather than JSON. Through the MCP connector the body comes back as { file, filename, content_type, size_bytes, data_base64 }. To get it to the customer, POST /api/invoices/{id}/send delivers it (email and/or text with the pay link); GET /api/invoices/{id}/link returns the public invoice page URL. |
POST /api/invoices/{id}/refund |
ledger:write |
Refund money a customer paid on an invoice (full or partial): { amount, refund_date?, from_account_id?, bank_transaction_id?, payment_method?, reference?, reason?, description?, account_id?, confirmClosedPeriod?, confirmReconciledPeriod?, confirm_other_customer? }. Issues a credit memo against the invoice and refunds it in one step, posting Dr sales returns (account_id, default the account assigned to the sales_returns role; with none assigned, the income accounts the invoice's lines credited, split like the lines, when any line names one, even though GET /api/accounts/roles shows sales_returns falling back to sales_discounts (that fallback is for credit memos); else sales_discounts, else revenue; debit_basis says which; on an opening-balance invoice from import-open, opening_balance_invoice: true and the note says those are import categories, so the refund lowers this period's income with no sale beside it) plus the invoice's proportional sales tax out of sales tax payable, Cr from_account_id. from_account_id defaults to the account the invoice's payments were deposited to when they all went to one account (from_account_defaulted, from_account_note); otherwise, with more than one cash or bank account, it must be named: 400 deposit_account_required lists at most 5 (accounts_total and more_accounts, GET /api/accounts?subtype=bank, for the rest), the accounts the invoice's payments were deposited to first, then for a payment from the customer's credit the account that credit was deposited to (each with why, also named in error), with a next_action per account. deposited_to in the response lists where the payments went. The amount is checked before the account. bank_transaction_id names the bank-feed row (money out) this refund is: from_account_id defaults to that row's bank account (400 from_account_mismatch for another), refund_date to its date (a row dated a day after today, as the import allows, refunds on that date), amount must equal the row (400 bank_amount_mismatch), 409 transaction_already_matched when the row is already in the books, 409 bank_row_other_customer when the row's text names a customer other than the invoice's (named_customer; rows_for_customer are the open rows that name the invoice's customer, any amount, amount_matches true first, a next_action for each that matches) unless confirm_other_customer once the owner says the row is this one; after posting the row is linked to the refund entry (bank_transaction { id, status matched, matched_entry_id }). debit_accounts in the response names the accounts debited. Afterwards the invoice reads amount_refunded, net_received and payment_status partially_refunded or refunded. amount is capped at what was paid on the invoice less earlier refunds. 400 refund_before_payment when refund_date is before the payments that cover the amount (first_payment_date, refundable_on_date). 409 nothing_paid (use a credit memo or void instead), fully_refunded, closed_period, reconciled_period when the date is on or before the statement date of that account's last completed bank reconciliation (resend with confirmReconciledPeriod: true once the owner agrees, or reopen it); 400 exceeds_refundable. |
POST /api/invoices/{id}/remind |
ledger:write |
Send a payment reminder (overdue reminder, nudge, follow-up on a late invoice) to the customer by email and/or text: the wording says how many days past due the invoice is and the balance due, with the view-and-pay link. Use this, not POST /api/invoices/{id}/send, to chase a sent invoice that is late; send only with the owner's OK. The platform sends no automatic invoice reminders, so reminders, last_reminded_at and reminder_count (also on GET /api/invoices/{id}) are the whole record: check them before reminding so the customer is not nudged twice. { channels?: ['email','sms'] } limits delivery (default both, when on file). { dry_run: true } previews would_send, skipped, days_past_due, balance_due and preview { email_subject, email_body, email_link (the View & Pay Invoice button the email adds), sms } without sending or recording; when delivers is false (no email or phone the customer can receive) it adds ask_owner and next_actions are the PATCH /api/customers/{id} to add a contact and a second dry run, never the real send. A dry run is never refused for a recent reminder: inside the 3-day window it still previews, with warnings [{ code: recently_reminded, last_reminded_at, days_since }], requires_repeat: true, would_send null, would_send_with_repeat (who the real send would reach) and ask_owner, and its next_action is the real send with repeat: true. The wording uses the number the customer knows: on an opening invoice renumbered on import (OLD-INV-0415) that is metadata.old_number (customer_invoice_number), as on the send, the public page and the PDF. Refusals (nothing sent): 409 not_sent (a draft: send it first), invoice_cancelled, nothing_due (paid), not_due_yet (not overdue: resend with before_due: true for a friendly coming-due reminder, owner's OK first) recently_reminded (a real send within 3 days of the last reminder: resend with repeat: true only if the owner says so) and no_recipient (a real send no requested channel can deliver: no email or phone the customer can receive, skipped says why, ask_owner, next_actions the PATCH /api/customers/{id} and a dry run; nothing is recorded); each carries the reminder history and the retry in next_actions. Reserved test recipients (*.example, 555-01xx numbers) are skipped as reserved_test_address. delivered (and success, the same) is true only when an email or text went out (a provider failure answers success: false with a delivery_failed warning); only then do last_reminded_at and reminder_count move. Every attempt is kept in the invoice's history. It never changes sent_at, the invoice's dates, aging or the books. |
POST /api/invoices/{id}/send |
ledger:write |
Mark a draft invoice sent (or resend a sent one, as the original invoice: to chase a late invoice use POST /api/invoices/{id}/remind, the overdue payment reminder) and deliver it to the customer: by default an email when the customer has an email AND a text message (SMS) with the pay link when they have a phone. { channels?: ['email','sms'] } limits delivery; { deliver: false, sent_on?: 'YYYY-MM-DD' } (or channels: []) marks it sent without emailing or texting anyone, for an invoice the owner already gave the customer on paper or from their own email: use it when the owner says the customer has the invoice, so they don't get a second copy (returns delivered: false and note; sent_on is the day they handed it over, stored as sent_at: not in the future and not before issue_date, else 400 invalid_sent_on; on an already-sent invoice a different sent_on corrects the sent date and the response says so in note and sent_at_changed { from, to }, while omitting it, or the same day, changes nothing). { dry_run: true } returns would_send (the recipients), skipped and delivers (false, with delivery_note, when no channel would reach the customer) without sending or changing the invoice, plus customer_open_credit and open_credit_note when the customer has open credit to use on it first. Reserved test recipients (*.example, *.test, *.invalid, *.localhost, example.com/.net/.org emails and fictional 555-01xx numbers) are never delivered to: they appear in skipped as { channel, reason: 'reserved_test_address', to } and the invoice is still marked sent. delivered is true only when an email or text actually went out: when every channel was skipped (no email or phone on file, test addresses only) or failed, the invoice is still marked sent but delivered is false, warnings [{ code: nothing_delivered | delivery_failed, message }] and ask_owner say so, and next_actions lead with PATCH /api/customers/{id} (add an email or phone), the resend and GET /api/invoices/{id}/link: never tell the owner the customer has the invoice then. A send that would deliver the same copy again (same balance due) on a channel it already reached the customer on in the last 24 hours answers 409 recently_sent { last_sent_at, last_sent_on (business date), days_since, ago, channels }, not_sent: true and ask_owner, with nothing delivered; { repeat: true } sends it anyway, only when the owner says so. A dry run then previews with warnings [recently_sent], recently_sent, requires_repeat: true and ask_owner, and its send next_action carries repeat: true; a resend's dry run also returns last_sent_at and deliveries_count (delivered sends so far). A copy after the balance changed, a mark-sent and a different channel are not guarded. sent_at is the latest send and first_sent_at the first (a resend never moves it); every send, resend and mark-sent is kept in the history on GET /api/invoices/{id} (deliveries). A resend of an overdue invoice adds days_past_due and overdue_note. Returns emailSent, smsSent, sent_to, skipped, balance_due, customer_open_credit (the customer's unused credit from a retainer, deposit or overpayment) and next_actions; with open credit the first next_action pays the invoice from it (from_customer_credit: true). { apply_customer_credit: true } (ask the owner first) pays what the customer's open credit covers, a deposit or retainer, as part of the send, before the email or text goes out, even a part of a draft invoice: the customer's copy shows the deposit applied and the balance due, and the response adds customer_credit_applied (0 when they had none); when that payment is refused (e.g. 409 closed_period) the invoice stays as it was, nothing is delivered, and the error carries not_sent: true. customer_credit_amount caps how much of the credit is used (default as much as the balance takes). The credit's payment is dated the latest of today, the credit's received_at and the invoice's issue_date (never before the money came in). With dry_run it previews would_apply_customer_credit, and the send next_actions carry apply_customer_credit and customer_credit_amount (the amount previewed) so following them applies the same credit. When customer_credit_amount is more than the open credit or the balance takes, the response adds customer_credit_capped { requested, applied, note } (tell the owner). A partial from_customer_credit payment on a draft through POST /api/invoices/{id}/payments answers 409 partial_on_draft with next_actions for this call. { apply_credit_memos: true } (ask the owner first) applies the customer's open credit memos, oldest first up to the balance due, as part of the send, before the email or text goes out, so the customer's copy shows the balance after the credit; the response adds credit_memos_applied [{ credit_memo_id, credit_memo_number, amount }] and credit_memo_applied_total (memos go on before apply_customer_credit). When the first memo is refused the invoice stays as it was, nothing is delivered and the error carries not_sent: true. Without it, a dry run (and the send response) reports open memos as customer_open_credit_memos { total, credits }, open_credit_memo_note and ask_owner, with a next_action that sends it with apply_credit_memos (from a dry run) or the POST /api/credit-memos/{id}/apply calls (after sending); with it, a dry run previews would_apply_credit_memos. |
POST /api/invoices/{id}/void |
ledger:write |
Void an unpaid draft or sent invoice (one sent in error, a duplicate). Sets status 'cancelled' and cancelled_at, keeps the invoice number, and removes it from AR aging (GET /api/invoices then shows payment_status void, balance_due 0 and amount_billed 0; sending, editing or paying it is 400/409 invoice_cancelled); optional reason is stored in metadata.void_reason. No ledger entry is reversed because invoices only post when paid. The customer is not notified. 409 has_payments for a paid or partly paid invoice, 409 has_applied_deposit when a sales-order deposit is applied, 409 already_cancelled, 409 has_credit_applied when a credit memo is applied. For a paid invoice use POST /api/invoices/{id}/refund; for a goodwill or partial correction, including on a partly paid opening-balance invoice, use POST /api/credit-memos with apply_to_invoice_id (the 409 has_payments next_actions carry the body). |
POST /api/invoices/import-open |
ledger:write |
Import the invoices still unpaid at the switch date when migrating (switching) from QuickBooks, Xero or a spreadsheet: content (QuickBooks A/R Aging Detail or Open Invoices report CSV; payments, credits and total lines are skipped, but a negative Payment / Credit Memo / Journal row with a customer is the customer's money on account (paid ahead) and comes back in unapplied_credits { total, rows, by_party, tell_owner }: tie_out expects A/R at the gross open invoices with that money in Customer Deposits, a posted A/R holding only the net answers open_items_net_of_credits (next_actions: the correction draft, debit A/R, credit Customer Deposits; confirm_difference is refused there unless unapplied_credits: leave), a gross A/R with no posted opening entry (or opening correction) putting that money in Customer Deposits answers unapplied_not_in_opening (not Ties: the invoices are still created, the credits stay pending; next_actions: the opening correction, credit Customer Deposits, debit the equity the opening used, for the owner to post), and the commit (also a resend once the invoices exist) registers each as that customer's credit (unapplied_credits: customer_credit, the default, against customer_credit_entry_id or the posted opening entry / opening correction found crediting Customer Deposits, never a payment, void or another customer's deposit; unapplied_credits.customer_credits says what was registered or pending); also Xero's Aged Receivables Detail, where the Due column is the open amount and Total - Paid - Credited must equal it, else the row is invalid; skipped_lines lists the skipped lines and file_total compares the file's Total line with the rows (matches: the file adds up; import_total is what the import creates, and duplicate_total / duplicate_rows the rows repeated in the file, counted in rows_total but created once)) or rows [{ number, customer | customer_id, date, due_date, open_amount, original_total, account_id | account_code }]. The export's original total (QuickBooks Amount or Xero Total beside the open amount, or rows[].original_total) shows on every preview row as original_total; a row whose open amount is more than its own total is invalid (columns crossed or a bad export), never imported. It is kept on the invoice when part was paid before the switch: rows[] and created[] show original_total, and GET /api/invoices and /api/invoices/{id} carry original_total and settled_before_switch (ar-aging records originalTotal / settledBeforeSwitch), so 'is that the 2,000 invoice, how much did they already pay' is answerable; the invoice still opens at the open amount. account_id / account_code is the row's income account (or income_account_id for every row; else the revenue role account): it never posts, but when the old books were cash basis it is what the cash-basis reports count the collection as; rows[].category shows what each row got, category_note counts the ones without, and POST /api/invoices/import-open/categories sets it after the import. An Account / Category column in the file (a chart code like 6152, "6152 Job Materials" or the account name) sets the row's category when it names an income account; account_column { header, categorized, ignored [{ row, value, reason }] } says which rows it set and why the others were left to the fallback (a column naming the A/P or A/R account itself is ignored). column_mapping and unused_columns show which headers were read and which were dropped. Each row becomes an opening_balance invoice for its open amount (sent, nobody notified, no sales tax added, payments clear the opening receivable instead of revenue), keeping its number. Customers are matched by name (import them first with POST /api/customers/import); an invoice number the business already has for that customer is skipped (numbers compare ignoring case, spaces and punctuation, so INV-0050, inv 0050 and INV0050 are one; with no number, same party, date and amount). A number the business already uses for ANOTHER customer's invoice (other than an opening invoice carried over on this receivable account) is number_conflict: a different invoice that still has to come over, counted in tie_out; number_conflicts [{ row, number, party, existing_id, existing_party, suggested_number }] and ask_owner propose suggested_number (number_prefix, default OLD-, + the old number), and the commit answers 409 number_conflict until it is resent with renumber_conflicts: true (created under suggested_number, old number in metadata.old_number and created[].old_number) or rows[].number set to the owner's pick; never correct the opening for it. Send preview: true first: rows say would_create | exists | exists_but_paid | number_conflict | duplicate_in_file | party_not_found | not_open | invalid (exists_but_paid: the export calls it open but it is paid here, so the export is stale or a payment went to the wrong document; paid_here lists them and ask_owner asks which is right; never import it again); numbers are matched before a row is checked, so a row whose number is already here is exists (or exists_but_paid) even when it is malformed (export_error says what is wrong with the export's line, e.g. Total - Paid - Credited not equal to Due) and never counts as left out; an exists or exists_but_paid row on an opening_balance invoice that carried over at another amount (the invoice's total, payments here aside) or other dates than the export shows carries differences [{ field: open_amount | issue_date | due_date | party, export, books }], matched_differences lists them, ask_owner asks which is right and next_actions GET the invoice with how to correct it; an amount difference makes tie_out matched_items_differ (ties false, difference = export - books) while the new invoices are still created; when the rows read tie but rows are party_not_found or invalid, tie_out is rows_not_read (ties false, rows_not_read { count, amount, file_total (the file's total), already_in { count, amount } (rows of the file already in the books) }, ask_owner whether the left-out ones are still owed: if so the opening A/R needs their amount too), never Ties, while the rows read are still created; tie_out compares the total with the opening receivable account (posted balance only, less open opening_balance invoices; draft entries are reported in draft_balance / draft_entry_ids, not counted). When it ties only by counting a draft opening entry the commit answers 409 opening_balance_not_posted (have the owner post it, then resend); when it doesn't tie, 409 open_items_do_not_tie; creates nothing, and ask_owner says what to ask; resend with confirm_difference: true once the owner confirms. Without opening_balance_account_id the standard Accounts Receivable account is used; account_choice names it and ties out the other receivable accounts (alternatives: any that tie, the standard one when another was picked and, in a preview that does not tie, the 3 closest by difference; alternatives_total counts them all), and when another one ties, next_actions resends with its id. A row dated after the switch (after the export's As of date, else the posted opening entry's date; the response's as_of) is status after_switch: new activity, not imported and not counted in tie_out; after_switch { switch_cutoff, rows, total, ask_owner, next_actions } carries the POST /api/invoices body that enters each as a normal invoice. include_after_switch: true imports them as opening items only when the owner says the old balance includes them. |
POST /api/invoices/import-open/categories |
ledger:write |
Set the income account (category) of invoices carried over at the switch date (opening_balance invoices from POST /api/invoices/import-open): items [{ id, account_id | account_code }]. Nothing posted changes, since their payments clear the opening receivable; when the old books were cash basis (prior_books_basis cash_basis), the cash-basis reports count their collections in this account instead of the revenue role account. A report's basis.opening_items_without_category lists the ones without one. Returns updated[] and failed[] (not_found, not_opening_item, invalid_category_account). |
GET /api/invoices/quota |
ledger:read |
Check the business's invoice quota for its plan tier. Returns { currentCount, limit, tier, allowed }; allowed is false once the limit is reached. |
/api/ledger 9
| Operation | Scope | What it does |
|---|---|---|
GET /api/ledger |
ledger:read |
Journal entries with their lines, newest first. Posted ledger only by default; drafts_excluded counts the unposted drafts in the same window, and include_drafts=true returns them too (each entry's status/source says which). ?from, ?to (YYYY-MM-DD), ?location_id. Large windows: page with ?limit (max 500) and ?offset (next_offset is set while more remain), and ?fields=compact returns just id, date, description, status and lines. GET /api/ledger/{id} reads one entry. To find entries by account or counterparty use GET /api/ledger/search. |
GET /api/ledger/{id} |
ledger:read |
One journal entry by id, any status (draft, posted, voided), with its lines (account code, name, type, debit, credit). Use it for the postedEntryId an invoice or bill payment returns. reversed_by { id, status, date } names the entry that undid it (its REVERSAL, or the void entry of a voided vendor credit or customer credit; null when none), and voided_document { kind: invoice_payment | bill_payment | vendor_credit_refund | vendor_credit | customer_credit, id, voided_at, void_reason, path } the payment, refund or credit whose void did. receipt_url is a signed link (valid 1 hour) to the receipt file when receipt_path is set, and receipt_text what was read from it; attach a late receipt, or replace a wrong one, with POST /api/ledger/{id}/receipt. |
PUT /api/ledger/{id}/contact |
ledger:write |
Link a journal entry (draft or posted, not voided) to a CRM contact, or unlink it with null. Only the link changes, never amounts or accounts, so posted entries allow it. Linked entries in loan and other receivable accounts show up in GET /api/crm/contacts/{id}/ar under non_invoice_receivables. Use it for the entries listed in that response's unlinked_counterparty_suggestions after the owner confirms the match. |
POST /api/ledger/{id}/recategorize |
ledger:write |
Move a posted entry's line to another account ("that should be Software & Subscriptions"). Posted entries are immutable, so this drafts a pair for the owner to post: reversal (entry_type REVERSAL, linked_entry_id = the original) and replacement (the original's lines with that line on the new account, replaces_entry_id = the original), both dated the original's date unless transaction_date is sent. Body { line_id? (optional on a two-line entry booked from a bank feed row: the non-bank line moves), account_id or account_code, transaction_date?, reason? }. Returns reversal, replacement, moves { from, to }, linked_bank_rows (each with the row's feed status and entry_status, the original's status: a row drafted from the feed keeps status draft_created after its draft is posted) and next_actions; once both are posted, the bank feed rows linked to the original move to the replacement (POST /api/drafts/{id}/post returns bank_rows_relinked). warning closed_period when that month is closed. next_actions also offers the merchant rule change (PATCH /api/merchant-rules/{id} or POST /api/merchant-rules) when the entry books a bank row, so next month's row lands on the new account. 409 replacement_exists when a replacement was already drafted with POST /api/entries/draft. 409 not_posted, voided, is_reversal, already_reversed (with reversal_id, replacement_id and next_actions to finish the existing pair: a retry does not need a new replacement). 400 codes unknown_account, line_required, same_account, same_account_both_sides. On an entry a bill, payment or credit posted, moving a line off or onto Accounts Payable is 409 document_entry (document, next_actions: void the bill, a vendor credit): A/P aging ties to the bills; moving an expense line or the bank line is fine. Moving the bank line itself (line_id on the account a linked bank feed row posts through) adds warning bank_line_moved { message, ask_owner, bank_transaction_ids, reconciliation_id?, statement_ending_date?, next_actions } (also in warnings): the money is on that account's statement, so the replacement no longer matches the row and a completed reconciliation stops tying; ask the owner, and discard the pair (DELETE /api/drafts/{id}?delete_pair=true) unless the statement row itself is wrong. |
POST /api/ledger/{id}/receipt |
quickcapture:write |
Attach the receipt image/PDF to a journal entry, including a posted or reconciled one (a receipt that arrived after the month was OK'd and posted). Documentation only: amounts, accounts and dates don't change, so no reversal is needed. JSON { filename, content_type, data_base64 } or multipart/form-data with a file field; 10 MB max. A file that does not decode as an image or PDF is 400 unreadable_file (nothing stored). On a posted entry the file is read first: receipt_text is filled, and a TOTAL that differs from the entry's amount is 409 receipt_mismatch { receipt_total, entry_amount, difference, receipt_text } (nothing stored); ask the owner, then resend with confirm: true if it is the right receipt. A posted entry that already has a receipt is 409 receipt_already_attached unless you send replace: true with a reason: the new file replaces it, the old file is kept and logged (GET /api/audit-log?resource_type=journal_entry&resource_id={id}). 409 duplicate_receipt (nothing stored) when the same file is on another entry. A capture or draft's receipt is simply replaced. GET /api/ledger/{id} returns receipt_url and receipt_text. |
POST /api/ledger/{id}/reverse |
ledger:write |
Correct a posted journal entry: creates a draft with the same accounts and amounts, debit and credit swapped, linked to the original (linked_entry_id, entry_type REVERSAL). The original stays posted; the owner posts the reversal with POST /api/drafts/{id}/post. Body { transaction_date? (default today; an opening, an entry with a line on Opening Balance Equity, defaults to its own date), same_date? (true dates it on the original's date, to cancel an error in its own month; false dates an opening's reversal today), reason? }; the note says the date used, and warning closed_period when that month is closed. 409 not_posted for a draft (edit it with PATCH /api/drafts/{id}), voided, is_reversal, already_reversed (with reversal_id). 409 document_entry when a bill, bill or invoice payment, vendor credit, refund or customer credit posted the entry, or a stock movement did (document { kind: inventory_movement }: a stock sale is undone with POST /api/inventory/movements/{id}/void, which puts the units back too; an adjustment with the opposite adjustment) (document { kind, id, parent_id, number }): reversing it alone would leave the bill open in A/P aging (or the invoice paid) while the ledger drops it, so next_actions name the document's own correction (POST /api/bills/{id}/void, POST /api/vendor-credits, POST /api/vendor-credits/{id}/void, POST /api/invoices/{id}/refund, or recategorize for one line); confirmDocumentEntry: true only for a full replacement that keeps the document's lines (the draft then carries warnings [{ code: document_unchanged }]: posting it alone leaves the document as it was). A payment or refund recorded in error is undone with its own void: POST /api/bills/{id}/payments/{paymentId}/void, POST /api/invoices/{id}/payments/{paymentId}/void, POST /api/vendor-credits/{id}/refunds/{refundId}/void, POST /api/customers/{id}/credits/{creditId}/refunds/{refundId}/void (a customer credit refund; refundId is its entry), POST /api/credit-memos/{id}/refunds/{refundId}/void (a credit memo cash refund), POST /api/customers/{id}/credits/{creditId}/void (a customer credit). To replace the entry, reverse it and record the corrected one with POST /api/entries/draft { replaces_entry_id }; when a replacement already exists, the response names it (replacements) and next_actions post the reversal then the replacement; to move one line to another account, POST /api/ledger/{id}/recategorize drafts both. linked_bank_rows lists bank feed rows booked by the original: they move to the replacement once the reversal and the replacement are posted. An unknown body key (date) answers 400 naming the field it meant (transaction_date). |
PUT /api/ledger/{id}/vendor |
ledger:write |
Link a journal entry (draft or posted, not voided) to the vendor it paid, or unlink it with null. Only the link (and a counterparty label that is the old vendor's name: it follows the new vendor, and unlinking clears it) changes, never amounts or accounts, so posted entries allow it. Use it for a contractor paid by Zelle, check or ACH straight from the bank with no bill: once the entry is posted and the vendor is_1099, GET /api/reports/1099-summary counts it (row directPaid). The entries to link are listed in that report's unattributed_contractor_expense.entry_ids. An old system's opening or year-to-date summary entry (opening_contractor_expense) answers 409 opening_summary_entry: record old-system totals with POST /api/vendors/{id}/prior-1099-payments instead. |
POST /api/ledger/export |
ledger:read |
Export posted journal entries as CSV with optional date filtering and account mapping. Sends the export via email instead of direct download. Body params: from (YYYY-MM-DD), to (YYYY-MM-DD), mapGroupId (optional UUID) |
GET /api/ledger/search |
ledger:read |
Search journal entries, drafts AND posted by default, by account (account_code=1250 or account_id), counterparty (partial, case-insensitive match on the free-text counterparty, e.g. "Brother"; an exact name as the ledger prints it matches too, parentheses, commas, & and dots included), crm_contact_id (entries linked to a CRM contact), q (description/note text), from/to date and status (draft|posted|all). Use it for non-invoice balances such as loans to people: "what does my brother owe me" = ?account_code=1250&counterparty=brother. With an account filter the response adds account: oldest-first activity with running_balance in the account's normal direction (sign_convention says it: positive receivable = owed to you, positive liability = owed by you, a negative liability was overpaid), plus posted_balance, draft_balance and balance through the last match; balance_scope is "filtered" (with balance_note) when counterparty, crm_contact_id or q narrow it, meaning those balances are that slice of the account and not its balance. With from, opening_balance (as of opening_as_of, the day before from) is what the same filters add up to before the window, the running balance and balances start from it, and period_net_change is the movement inside the window alone. Entries come newest first with all their lines; limit caps them (default 100, max 500) and truncated says if more matched. |
/api/location 1
| Operation | Scope | What it does |
|---|---|---|
POST /api/location/select |
|
Set the active business_location_id for location-aware pages. A null/absent location_id clears the selection (= All locations). Validates that the location belongs to the current business. Body: { location_id: string | null } |
/api/locations 1
| Operation | Scope | What it does |
|---|---|---|
GET /api/locations/overview |
locations:read |
Per-site snapshot for each active business location: open deals, upcoming bookings, low-stock alerts, documents expiring within 30 days, and month-to-date net income. Returns { period_start, period_end, locations }. |
/api/manifest 1
| Operation | Scope | What it does |
|---|---|---|
GET /api/manifest |
|
The catalog of API endpoints: path, method, required scopes, read/write and a summary. ?mine=1 limits it to what this key can call; ?profile=bookkeeping to the accounting groups; ?path=<path>&method=<METHOD> describes one endpoint with its bodySchema / querySchema and a short summary (a long one's edge cases come back as details with &verbose=1; without it, details_index names what they cover). Read GET /api/context first (start_here). |
/api/marathon 5
| Operation | Scope | What it does |
|---|---|---|
DELETE /api/marathon/threads/chat |
|
Clear one of the caller's Marathon threads (?thread_id=) by deleting it and its messages. User sessions only. |
GET /api/marathon/threads/chat |
|
Load the messages of one of the caller's Marathon threads (?thread_id=). Returns { messages, threadId }. User sessions only. |
POST /api/marathon/threads/chat |
|
Send a message in a Marathon audit thread; the AI answers with the audit (or audit item) as context. User sessions only. |
POST /api/marathon/threads/item |
|
Find or create the discussion thread for one item of a Marathon audit the caller owns. Returns { threadId, thread }. User sessions only. |
POST /api/marathon/threads/report |
|
Find or create the caller's report-level Marathon discussion thread for a scope (personal, all businesses, or one business). Returns { threadId, thread }. User sessions only. |
/api/merchant-rules 4
| Operation | Scope | What it does |
|---|---|---|
GET /api/merchant-rules |
ledger:read |
List the business's merchant rules. Each books bank rows whose merchant name or description matches merchant_name (exact) or contains merchant_pattern, on every bank and card account, to category_account_id. |
POST /api/merchant-rules |
ledger:write |
Create a merchant rule so future bank rows from a merchant categorize themselves. Rules are business-wide: one applies to every bank and card account's rows. A bank, cash or credit card category_account_id (a transfer or card payoff) is refused with 400 transfer_category_not_allowed unless confirm_transfer_rule: true, which returns a warning naming the accounts it applies to. Generic bank text with no stable payee (MOBILE DEPOSIT, CHECK 1043, ATM, ONLINE TRANSFER, ZELLE FROM; the bank's name, dates and reference numbers around it do not count as a payee, so CHASE MOBILE DEPOSIT REF 88213 is generic too) as merchant_pattern, or as merchant_name without a pattern, is refused with 400 generic_merchant_text unless confirm_generic_rule: true: such rows are categorized one by one. 409 when a rule for the merchant exists (PATCH it). |
DELETE /api/merchant-rules/{id} |
ledger:write |
Delete a merchant rule. |
PATCH /api/merchant-rules/{id} |
ledger:write |
Update a merchant rule (business-wide: it applies to every bank and card account's rows). A bank, cash or credit card category_account_id is refused with 400 transfer_category_not_allowed unless confirm_transfer_rule: true. A merchant_name or merchant_pattern change to generic bank text with no stable payee (MOBILE DEPOSIT, CHECK 1043, ATM, ONLINE TRANSFER) is refused with 400 generic_merchant_text unless confirm_generic_rule: true. |
/api/nav 4
| Operation | Scope | What it does |
|---|---|---|
DELETE /api/nav/menu |
settings:write |
Reset both personal menu lists so the product's default menu applies. Returns the menu, as GET. |
GET /api/nav/menu |
settings:read |
The caller's personal menu: pinned sidebar item ids and mobile bottom-bar ids. Returns { pinned, mobile, customized, mobileCustomized, mobileLimit }. |
PUT /api/nav/menu |
settings:write |
Replace the caller's pinned sidebar items and/or mobile bottom-bar items (an omitted list is left alone). Returns the updated menu, as GET. |
GET /api/nav/screens |
settings:read |
Every screen in this product (title, path, keywords) for the app's sidebar screen search. Large (40-60KB); an agent rarely needs it: use GET /api/manifest to find API routes. |
/api/opening-balance 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/opening-balance |
ledger:read |
Opening balance status: whether it is recorded, the books start date, and the posted opening entry |
POST /api/opening-balance |
ledger:write |
Record starting balances as of booksStartDate as one posted entry; the difference goes to Opening Balance Equity. Match accounts by account_id, account_code or name; negative amounts go on the opposite side (draws, accumulated depreciation, negative retained earnings). preview: true returns the plan without writing (total_debits and total_credits include the plug line; already_recorded: true with existing_entry_id means an opening exists and saving will 409; correction_dates then gives the date each account's correcting draft entry takes: the existing opening's date; for an account first used later, the day before its first statement; for an account with nothing posted before a booksStartDate later than the existing opening (a later switch, or a catch-up account's first statement start), the day before booksStartDate, reason switch_date); adopt_entry_id marks an existing posted journal entry as the opening |
/api/orders 4
| Operation | Scope | What it does |
|---|---|---|
GET /api/orders |
orders:read |
List orders for the current business. Optional ?type= and ?status= filters (an unknown status is a 400 listing ORDER_STATUSES). Each row carries its linked scheduling_booking (customer + scheduled time) when one exists. |
GET /api/orders/{id} |
orders:read |
Order detail with its lines and linked booking. |
PATCH /api/orders/{id} |
orders:write |
Update an order. A status change is gated by the domain transition rules (submitted -> in_progress -> complete, with cancellation from either non-terminal state); the ledger-style status machine lives in the domain, not here. |
POST /api/orders/{id}/invoice |
ledger:write |
Generate a draft invoice from a generic order (e.g. a DMV order), the order->invoice analogue of POST /api/sales-orders/:id/invoice. Each invoice line bills the order line's uninvoiced quantity (ordered minus already-invoiced across non-cancelled invoices) at the order line's unit price, carrying line_type so the service-fee (revenue) vs dmv_fee (pass-through) split survives onto the invoice and is honoured at payment (post-payment-journal.ts). The invoice links back to the order (and each line to its order line) for the derived billing status. Draft only: the operator reviews and sends it. A generic order has no customer_id (only an optional booking), so the caller supplies customer_id in the body -- checkout/portal resolves the customer from the booking before calling this. Returns 400 when nothing remains unbilled, 422 when customer_id is missing/unknown. |
/api/periods 5
| Operation | Scope | What it does |
|---|---|---|
GET /api/periods |
ledger:read |
List accounting periods (?limit=, default 18 most recent rows; months never closed have no row). books_closed_through is the latest closed month: posting into it or any earlier month needs confirmClosedPeriod: true. open_before_close lists months left open before it, which are locked anyway. |
GET /api/periods/audit |
ledger:read |
Get audit log for period controls Optional query params: year, month, limit |
POST /api/periods/close |
ledger:write |
Close a month. The books close through a date: once a month is closed, posting into it or any earlier month needs confirmClosedPeriod: true. Runs the close checklist first (period ended, unposted drafts, bank reconciliations, bank feed review, GR/IR and suspense balances, unapproved bills, unsent invoices, earlier months left open); any failure returns 409 period_close_checks_failed with each check and next_actions, unless confirmCloseChecks: true. A month that has not ended yet (its last day is today or later in the business's time zone) is 409 period_not_ended, which confirmCloseChecks does not override. note (the reason, saved as close_note) is kept with the override record. Preview with GET /api/periods/close-checklist. |
GET /api/periods/close-checklist |
ledger:read |
Preview the month-end close checklist for ?year&month (or ?month=YYYY-MM alone) without closing: each check (period_ended, unposted_drafts, bank_reconciliations (each bank account with activity, or with a feed and an earlier reconciliation but nothing this month, needs a completed reconciliation for the month), reconciled_balances (an entry posted into a completed reconciliation's statement period afterwards, so the books no longer tie to that statement), bank_feed_review, clearing_accounts, unapproved_bills, unsent_invoices, prior_periods_closed) with passed, detail and the ids involved, plus next_actions. ready=true means POST /api/periods/close will go through without confirmCloseChecks. period_ended is the one check confirmCloseChecks does not override: a month is closed only after its last day (today is the business's own date). ?brief=true keeps it small on busy books: passed checks as { key, passed }, each failed check with the first 3 parts of its detail and the first 5 ids plus ids_total. ?limit=N caps the ids each check lists at N (ids_total added when cut) and its detail at the first N accounts or records ("and 33 more"), id lists inside the detail at N too, with or without brief. |
POST /api/periods/reopen |
ledger:write |
Reopen a closed month: { year, month, reason }. The books close through a date, so while a later month stays closed the reopened month is still locked; the response's still_locked_by names that month (null when posting is free again). |
/api/pm 69
| Operation | Scope | What it does |
|---|---|---|
GET /api/pm/activity |
pm:read |
Recently touched tasks, newest first: id, title, status, project, assignee names and when. ?limit= caps the list (1 to 100, default 100). |
GET /api/pm/boards |
pm:read |
Flat list of routing targets (project + list) with a one-line descriptor per board, so an agent can content-match where a task lands. Backs `workforce list-boards`. |
GET /api/pm/field-definitions |
pm:read |
List every task field definition: the built-in fields (status, priority and the like) plus the business's custom fields. |
POST /api/pm/field-definitions |
pm:write |
Create a custom task field for the business. Body { field_key, label, field_type, ... }. Returns the new field definition (201). |
DELETE /api/pm/field-definitions/{id} |
pm:write |
Delete a custom task field. Built-in fields cannot be deleted. 404 when the business has no such field. |
PATCH /api/pm/field-definitions/{id} |
pm:write |
Edit a custom task field (label, options, order). Built-in fields such as status or priority cannot be edited (400). Returns the updated definition. |
PATCH /api/pm/field-definitions/reorder |
pm:write |
Set the display order of the task fields. Body { orderedIds }: every field definition id, in the new order. |
GET /api/pm/labels |
pm:read |
Distinct label names used in the business, for autocomplete so freeform labels stay consistent (no shared catalog). |
GET /api/pm/lists/{listId}/board-config |
pm:read |
A list's board layout: its custom columns (empty means one column per workflow status), whether subtasks show as cards, and how long completed tasks stay on the board. |
PUT /api/pm/lists/{listId}/board-config |
pm:write |
Update a list's board layout. Body { columns?, show_subtasks?, completed_retention_days? }; fields you leave out keep their saved value. columns: [] resets the board to one column per status. completed_retention_days: null inherits the business setting, 0 keeps completed tasks on the board forever. Column rules (unique names, one column per status, statuses from the list's workflow) return a 422 naming the problem. |
GET /api/pm/lists/{listId}/fields |
pm:read |
The task field catalog (role fields plus the business's custom fields) for a task list. 400 invalid_id for a malformed listId, 404 for a list this business does not have. |
GET /api/pm/lists/{listId}/statuses |
pm:read |
The workflow statuses a task list resolves to (its own set, else its project's, else the business default). 400 invalid_id for a malformed listId, 404 for a list this business does not have. |
GET /api/pm/me |
pm:read |
Who this request acts as: the AI employee named by the X-Workforce-Employee header, or the signed-in user. An API key without the header gets id: null and a message saying how to act as an employee. |
GET /api/pm/projects |
pm:read |
List projects for the current business. |
POST /api/pm/projects |
pm:write |
Create a project. |
PATCH /api/pm/projects/{id} |
pm:write |
Edit or archive a project. Archive with { status: "archived" }; projects are never deleted, because their lists and tasks stay attached to them. Returns the updated project. |
GET /api/pm/projects/{id}/lists |
pm:read |
List the lists in a project. |
POST /api/pm/projects/{id}/lists |
pm:write |
Create a list in a project. The list's workflow is chosen at creation time: `status_preset` seeds it from a built-in template (see GET /api/pm/status-presets), or `status_set_id` attaches an existing saved set. Neither means the list inherits its project's workflow. |
DELETE /api/pm/projects/{id}/lists/{listId} |
pm:write |
Delete an empty list. Refused when the list still holds tasks to avoid orphaning them. |
PATCH /api/pm/projects/{id}/lists/{listId} |
pm:write |
Rename, reorder, or set the list's status set. |
GET /api/pm/recent-comments |
pm:read |
Business-wide comment activity for the last N hours (newest first), for the dashboard "last 24 hours" summary. |
GET /api/pm/recent-comments/summary |
pm:read |
A short plain-English digest of recent task comments, as markdown in summary_md (null when no AI provider is set up). Served from a saved snapshot that refreshes once it is a day old; POST regenerates it now. |
POST /api/pm/recent-comments/summary |
pm:read |
Regenerate the recent-comments digest now, even when the saved one is fresh, and return it. Each call spends an AI model call. |
GET /api/pm/status-presets |
pm:read |
The built-in workflow templates (status presets) a list or project can start from. Pass a key as { preset } to POST /api/pm/status-sets, or as { status_preset } to POST /api/pm/projects/{id}/lists. |
GET /api/pm/status-sets |
pm:read |
List the business's named workflows (status sets) and which one is the default. |
POST /api/pm/status-sets |
pm:write |
Create a named workflow (status set). Body { preset } seeds it from a built-in template (keys from GET /api/pm/status-presets), or { name, statuses } gives the ordered status names; name and statuses may also override a preset. is_default: true makes it the business default. 409 when a set with that name exists, 422 when the statuses break the ordering rules. |
DELETE /api/pm/status-sets/{id} |
pm:write |
Delete a status set. Lists and projects that used it fall back to the business default workflow. 404 when the business has no such set. |
PATCH /api/pm/status-sets/{id} |
pm:write |
Rename a status set, change its statuses (the full ordered list) or make it the business default. 409 when a list using the set holds a task in a status the edit would drop. |
POST /api/pm/status-sets/preview |
pm:read |
Preview assigning a status set to a list or project, without changing anything: the statuses the new workflow drops and the tasks currently in them. status_set_id: null previews going back to the inherited workflow. |
GET /api/pm/tasks |
pm:read |
List tasks (archived ones left out). Filters: ?project_id=, list_id=, status= (e.g. "to do", "waiting on", "complete"; the set is on GET /api/pm/tasks/{id}/statuses), assignee_id= (an employee slug, e.g. the owner's owner.employee_slug from GET /api/setup/status), related_type= and related_id= (the record a task was created about with related, its full UUID: anything else is 400 invalid_id, e.g. related_type=vendor&related_id=<vendor id> finds the W-9 task; add status filtering yourself: an open task is one not in complete), updated_since= (ISO timestamp); include_archived=1 adds archived tasks. ?fields=id,title,status (comma-separated task keys) returns only those keys on each task (400 unknown_fields names the available keys). ?shape=platform (optional, used by the AI workforce) returns the camelCase PmTask shape instead. With it, mine=1 lists only the calling employee's tasks. |
POST /api/pm/tasks |
pm:write |
Create a task, e.g. to ask the owner for a statement or receipts: { title, instructions (the exact ask, with dates and amounts), assignee_employee_ids, priority (low, normal, high, urgent), status }. To assign the business owner use owner.employee_slug from GET /api/setup/status; other assignees are employee slugs (not uuids). Omit list_id and project_id to use the default project; parent_task_id makes a subtask. related: { type: vendor | customer | invoice | bill | bank_transaction | account (a chart account such as a bank; bank_account is accepted as an alias) | entry (a capture, draft or posted entry, e.g. a missing receipt), id } ties the task to the record it is about (404 related_not_found if it isn't in this business): put it on every owner ask about a record, e.g. { type: "vendor", id } on a W-9 ask, so GET /api/pm/tasks?related_type=vendor&related_id= finds it and later missing_tax_id warnings name it as open_task instead of asking for a new task. With related set, a task about the same record still open on the same assignee's list (not complete or archived; recurring schedules aside) answers 409 open_task_exists { existing [{ id, title, status, created_at, assignee_ids }], next_actions } and creates nothing: read that task's comments and add the new ask there as a comment; resend with confirm_new_task: true only for a really separate ask. Returns { task, next_actions }: the task row with related_type, related_id and assignees ([{ id: employee slug, name, kind }], so you can tell the owner it is on their to-do list), and next_actions (read the owner's replies, set waiting on, update the related record, close it). Its id is what the other /api/pm/tasks/{id} calls take. ?shape=platform (optional, used by the AI workforce) returns the camelCase PmTask shape instead. |
DELETE /api/pm/tasks/{id} |
pm:write |
Delete a task. To close a finished ask, PATCH /api/pm/tasks/{id} { status: "complete" } instead. |
GET /api/pm/tasks/{id} |
pm:read |
Read one task: title, description, instructions, status, priority, due_at, and assignees ([{ id: employee slug, name, kind }]; the owner is owner.employee_slug from GET /api/setup/status). ?fields=id,title,status (comma-separated task keys, e.g. assignees, updated_at) returns only those, so checking a task with a long description stays small. Comments (the owner's answers) on GET /api/pm/tasks/{id}/comments. ?shape=platform (optional, used by the AI workforce) returns the camelCase PmTask shape instead. |
PATCH /api/pm/tasks/{id} |
pm:write |
Update a task: title, description, instructions, priority (low, normal, high, urgent), due_at, cadence, or status. To change part of a long description or instructions (a running checklist) without resending it, use description_edits [{ old_str, new_str }] (each old_str must match exactly once; all or nothing, 409 edit_not_found / edit_not_unique names the edit) and/or description_append (added on a new line); instructions_edits / instructions_append work the same. ?fields=id,status,updated_at returns only those keys of the updated task. Status takes a value from GET /api/pm/tasks/{id}/statuses, e.g. "waiting on" while you wait for the owner, "complete" when done; a status change notifies the assignees. Returns the updated task with its assignees. ?shape=platform (optional, used by the AI workforce) returns the camelCase PmTask shape instead. |
POST /api/pm/tasks/{id}/archive |
pm:write |
Archive or restore a task. Body { archived: boolean }: true archives (an empty body does too), false restores. Archived tasks leave boards and queues; nothing is deleted. Returns { task }. |
PUT /api/pm/tasks/{id}/assignees |
pm:write |
Set who a task is assigned to. Body { employee_ids }: the full list of employee slugs after the change ([] unassigns everyone); missing ones are added, others removed. To assign the business owner use owner.employee_slug from GET /api/setup/status; other assignees are employee slugs (not uuids). Returns { assignees }. |
GET /api/pm/tasks/{id}/attachments |
pm:read |
List the files attached to a task, each with a short-lived signed download url (fetch it soon; call again for a fresh one). Files the owner attached to a comment are on GET /api/pm/tasks/{id}/comments instead. A bank or card statement (PDF or photo) attached here is read directly by POST /api/bank-feed/import/json { pm_attachment_id: <attachment id>, chart_account_id, dry_run: true }; no need to download and re-upload it (see the catch-up guide in get_context). |
POST /api/pm/tasks/{id}/attachments |
pm:write |
Attach files to a task (25 MB each): JSON { files: [{ filename, content_type, data_base64 }] } or multipart/form-data with one or more files fields. Returns each file with a signed url. |
GET /api/pm/tasks/{id}/attachments/{attachmentId}/download |
pm:read |
Download one task attachment: redirects (302) to a fresh short-lived signed url for the file. attachmentId comes from GET /api/pm/tasks/{id}/attachments. |
GET /api/pm/tasks/{id}/comments |
pm:read |
List a task's comments, oldest first: the owner's answers to a task you assigned them. Each attachment carries a short-lived signed url. ?limit= and before= page back; archived= filters archived comments. ?shape=platform (optional, used by the AI workforce) returns the camelCase PmTask shape instead. |
POST /api/pm/tasks/{id}/comments |
pm:write |
Comment on a task (follow up with the owner, record what you did). Body { body } (markdown); mentionedEmployeeIds (employee slugs) notifies those people. Returns the comment. ?shape=platform (optional, used by the AI workforce) returns the camelCase PmTask shape instead. |
POST /api/pm/tasks/{id}/comments/{commentId}/archive |
pm:write |
Archive or restore one comment on a task. Body { archived: boolean }: true archives (an empty body does too), false restores. An archived comment leaves the thread and the comments AI employees read, so use it to retract an instruction that was wrong or superseded. Nothing is deleted. Returns { comment }. |
POST /api/pm/tasks/{id}/comments/attachments |
pm:write |
Upload files (images or PDFs) to attach to a task comment, as multipart/form-data with one or more files fields. Returns the attachment metadata to pass in the attachments of POST /api/pm/tasks/{id}/comments. 404 when the task is not in this business. |
GET /api/pm/tasks/{id}/context |
pm:read |
A task's subtasks, attachments and docs in one call. |
GET /api/pm/tasks/{id}/docs |
pm:read |
List the markdown docs attached to a task. |
POST /api/pm/tasks/{id}/docs |
pm:write |
Create a markdown doc on a task. |
GET /api/pm/tasks/{id}/docs/{docId} |
pm:read |
Read one task doc, business- and task-scoped. |
GET /api/pm/tasks/{id}/fields |
pm:read |
A task's fields: the field catalog for its list plus the task's current value for each. |
PUT /api/pm/tasks/{id}/fields |
pm:write |
Set one field value on a task. Body { fieldId, value }, with field ids from GET /api/pm/tasks/{id}/fields. |
DELETE /api/pm/tasks/{id}/labels |
pm:write |
Remove a label from the task. The label goes in the query string: DELETE /api/pm/tasks/{id}/labels?labelId=<id>, with the id from GET /api/pm/tasks/{id}/labels. 404 when the task has no such label. |
GET /api/pm/tasks/{id}/labels |
pm:read |
List the task's labels. |
POST /api/pm/tasks/{id}/labels |
pm:write |
Add a freeform label. Body: { name, color? }. |
GET /api/pm/tasks/{id}/links |
pm:read |
List the links touching this task: tasks that block it and tasks it blocks. |
POST /api/pm/tasks/{id}/links |
pm:write |
Mark another task as blocking this one. Body { blockerTaskId, linkType? }: {id} is the blocked task, blockerTaskId the one it waits on. While the blocker is open this task moves to "waiting on". Returns the link. |
DELETE /api/pm/tasks/{id}/links/{linkId} |
pm:write |
Remove a link between tasks. {id} is the blocked task and {linkId} comes from GET /api/pm/tasks/{id}/links; a task no longer blocked goes back to its previous status. 404 when the task or link does not exist or the link does not touch the task. |
POST /api/pm/tasks/{id}/move |
pm:write |
Move a task to another list. Body { list_id } (ids from GET /api/pm/boards). When that list is in another project the task moves to that project too, and its subtasks move with it. Returns the moved task. |
DELETE /api/pm/tasks/{id}/pin |
pm:read |
Unpin the task from the signed-in user's dashboard. |
POST /api/pm/tasks/{id}/pin |
pm:read |
Pin the task to the signed-in user's dashboard. Pinning an already pinned task does nothing. Needs a signed-in user: an API key has no dashboard (400). |
POST /api/pm/tasks/{id}/promote |
pm:write |
Promote a subtask to top-level by clearing its parent_task_id. The task keeps its list/project; only the parent link is severed. Rejects a task that is already top-level. |
POST /api/pm/tasks/{id}/reparent |
pm:write |
Change a task's parent. Body { parent_task_id }: a task id makes this task a subtask of it (or moves it to a new parent); null makes it a top-level task again. The task and its subtasks move to the new parent's list and project. 400 when the change would make a task its own ancestor. |
PUT /api/pm/tasks/{id}/status |
pm:write |
Change a task's status. Body { status }, a value from GET /api/pm/tasks/{id}/statuses. The assignees are notified. Returns the updated task. |
GET /api/pm/tasks/{id}/statuses |
pm:read |
List the statuses a task can be moved to, in workflow order (e.g. to do, in progress, waiting on, complete). Use one as PATCH /api/pm/tasks/{id} { status }. |
GET /api/pm/tasks/{id}/subtasks |
pm:read |
A task's subtasks. Compact by default (description and instructions left out, has_description flags them); ?fields=full returns whole rows. 404 for a task this business does not have. |
GET /api/pm/tasks/assigned |
pm:read |
One employee's open tasks (assigned, not archived, not complete), most recently touched first. ?employee=<slug> is required. |
GET /api/pm/tasks/mine |
pm:read |
Tasks currently assigned to the caller ("what needs my attention"), most recently touched first. A session user resolves to their linked employee; an API key resolves via the X-Workforce-Employee header. No resolvable employee means no tasks. |
GET /api/pm/tasks/pinned |
pm:read |
The signed-in user's pinned tasks, in pin order. Pins are personal: an API key has no pins and gets a 400. |
PUT /api/pm/tasks/pinned |
pm:read |
Reorder the signed-in user's pinned tasks. Body { taskIds }: the pinned task ids in the new order. |
GET /api/pm/tasks/search |
pm:read |
Search tasks by text: ?q= matches titles and descriptions (case-insensitive, archived tasks left out), e.g. to find an open ask to the owner before creating a duplicate. |
GET /api/pm/tasks/titles |
pm:read |
Current titles for a batch of task ids, keyed by id. Backs ticket references in rendered comments so they show the live title (and stay correct after a rename) rather than a raw id. |
/api/posted 2
| Operation | Scope | What it does |
|---|---|---|
DELETE /api/posted/{id} |
ledger:write |
Delete a posted journal entry (practice mode only; live books answer 403: correct with POST /api/ledger/{id}/reverse) |
PATCH /api/posted/{id} |
ledger:write |
Update a posted journal entry (practice mode only; live books answer 403: correct with POST /api/ledger/{id}/reverse) Body: { description?: string, debitLines?: { accountId: string, amount: number }[], creditLines?: { accountId: string, amount: number }[] } |
/api/pricing 10
| Operation | Scope | What it does |
|---|---|---|
GET /api/pricing/price-lists |
pricing:read |
List the business's price lists. Optional ?active=true|false (anything else is a 400). |
POST /api/pricing/price-lists |
pricing:write |
Create a named price list. Items are managed via the nested items routes. |
DELETE /api/pricing/price-lists/{id} |
pricing:write |
Delete a price list permanently, with its tiers. Customers assigned to it are unassigned and fall back to each product's sell price. PATCH { is_active: false } keeps it instead. |
GET /api/pricing/price-lists/{id} |
pricing:read |
The list plus its quantity-break items (for a price-list builder UI). |
PATCH /api/pricing/price-lists/{id} |
pricing:write |
Rename, toggle active, or update metadata. Deactivating with is_active: false is the soft-off (customers keep their assignment; resolution still reads it). |
GET /api/pricing/price-lists/{id}/items |
pricing:read |
The quantity-break tiers on a price list, ordered by product then tier floor. |
POST /api/pricing/price-lists/{id}/items |
pricing:write |
Add a quantity-break tier (product + min_quantity + unit_price) to the list. The (business_id, price_list_id, product_id, min_quantity) unique index rejects a duplicate tier as a 409. |
DELETE /api/pricing/price-lists/{id}/items/{itemId} |
pricing:write |
Remove one quantity-break tier. |
PATCH /api/pricing/price-lists/{id}/items/{itemId} |
pricing:write |
Update one tier's product, min_quantity, or unit_price. |
GET /api/pricing/resolve |
pricing:read |
The sell price for a sales order line, as POST /api/sales-orders would apply it. ?product_id (required, UUID), ?customer_id (UUID; uses the customer's price list), ?quantity (base unit, default 1), ?entered_price (checks a typed price for anomalies). Returns resolvedPrice (the customer's price-list tier for that quantity, else the product's sell price), fallbackSellPrice, priceListId, matchedTier and anomaly (when entered_price is far from resolvedPrice, else null). |
/api/product-feeds 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/product-feeds |
catalog:read |
List the business's supplier product feed configs (catalog imports from a vendor price/stock file or url; no ledger effect). Optional ?active=true|false; any other value is a 400. |
POST /api/product-feeds |
catalog:write |
Create a supplier product feed config. A url source needs source_url; only url feeds can be scheduled, and a schedule needs next_execution_date. Try the mapping first with POST /api/product-feeds/preview. |
DELETE /api/product-feeds/{id} |
catalog:write |
Delete a supplier product feed config. Products it already imported stay in the catalog and its run history is kept; to stop it without deleting, PATCH is_active: false. 400 for a malformed id, 404 for an unknown one. |
GET /api/product-feeds/{id} |
catalog:read |
One supplier product feed config by id (source, format, field mapping, schedule, active flag). 400 for a malformed id, 404 for one not in this business. |
PATCH /api/product-feeds/{id} |
catalog:write |
Update a supplier product feed config; send only the fields to change. The url/schedule rules are checked against the stored config plus the patch (400 naming the field). 400 for a malformed id, 404 for an unknown one. |
POST /api/product-feeds/{id}/run |
catalog:write |
Run a stored supplier product feed now: url feeds are fetched by the server, upload feeds need the file in content. Writes catalog products and returns the run (created, updated, skipped and error counts, with the first row errors). 400 for a malformed id or a feed that cannot run, 404 for an unknown id. |
GET /api/product-feeds/{id}/runs |
catalog:read |
Run history (newest first) for one supplier product feed config. 400 for a malformed id, 404 for one not in this business. |
POST /api/product-feeds/preview |
catalog:read |
Parse, map and match a supplier feed against the catalog without writing anything, to check a mapping before creating or running a feed config. |
/api/purchase-orders 11
| Operation | Scope | What it does |
|---|---|---|
GET /api/purchase-orders |
purchasing:read |
List purchase orders (inventory purchasing: goods ordered into a warehouse, or drop-shipped), newest first, filterable by ?status, vendor_id, warehouse_id, location_id. Returns { purchaseOrders }. Bookkeeping for a service purchase or a vendor invoice in hand is a bill (POST /api/bills), not a PO. |
POST /api/purchase-orders |
purchasing:write |
Create a draft purchase order for goods from a vendor, into a warehouse (or is_drop_ship to ship straight to a customer). Each line names a product (stocked goods) or an expense account; a line without a description takes the product's name, and a line without a unit_cost takes the vendor's cost list price, else the product's cost. Nothing posts to the books yet: PATCH { status: "submitted" } sends it, POST /api/purchase-orders/{id}/receive brings the goods in (Dr Inventory / Cr Goods Received Not Invoiced), and the vendor's bill (POST /api/bills with purchase_order_id) clears that. For a vendor invoice in hand without an order, use POST /api/bills. Returns { purchaseOrder, next_actions }. Any key not listed in the body is a 400 with a did-you-mean hint. |
GET /api/purchase-orders/{id} |
purchasing:read |
Full purchase order: header, vendor + warehouse, lines (with product), and every receipt event with its lines. |
PATCH /api/purchase-orders/{id} |
purchasing:write |
Two shapes: - { status: "submitted" | "cancelled" } transitions the order (validated against the lifecycle; received/partially_received are receiving-driven, not manual targets). - header field edits (vendor, warehouse, dates, notes) while still draft. `received`/`partially_received` are never set here -- they flow from receiving. |
POST /api/purchase-orders/{id}/approve |
purchasing:write |
Approve a purchase order awaiting approval: it becomes a live commitment to the vendor (submitted), with the approver and time recorded for audit. Requires purchasing:write; self-approval is allowed (single-operator businesses). Reject is the sibling action (../reject). |
POST /api/purchase-orders/{id}/confirm-ship |
purchasing:write |
Confirm that a drop-ship purchase order's vendor shipped direct to the end customer. Records vendor_shipped_at / vendor_tracking_ref, closes the PO (status `received`), and advances every linked sales-order line so a drop-ship SO line fulfills against this confirmation instead of the pick/pack/ship flow. The drop-ship analog of the receive endpoint; a non-drop-ship PO is rejected 409. |
POST /api/purchase-orders/{id}/receive |
purchasing:write |
Receive part or all of a submitted purchase order into a warehouse (a draft order is submitted first with PATCH { status: "submitted" }). Each received line adds stock at the received unit cost and posts Dr Inventory / Cr Goods Received Not Invoiced; the order becomes partially_received or received. Supports partial receipts and a per-receipt warehouse override. received_date (YYYY-MM-DD, default the business's today) dates the receipt and its entry; the response's ledger { entry_ids, date } names the posted entries. The response's next_actions carries the POST /api/bills body for the vendor's bill (purchase_order_id and each purchase_order_line_id), which clears Goods Received Not Invoiced. 400 with code invalid_status, quantity_exceeds_remaining, unknown_order_line or expense_line_not_receivable (issues names the line), or future_date (received_date after today); 409 account_roles_missing or closed_period (received_date in a closed month; nothing is received). |
POST /api/purchase-orders/{id}/reject |
purchasing:write |
Reject a purchase order awaiting approval: it returns to draft so the operator can edit and resubmit, with the rejector, time, and optional reason recorded. Requires purchasing:write. Approve is the sibling action (../approve). |
GET /api/purchase-orders/{id}/return |
purchasing:read |
List the vendor returns already booked against this purchase order, plus the quantity returned so far per purchase-order line. Returns { returns, returnedByLine: { [purchase_order_line_id]: quantity } }. |
POST /api/purchase-orders/{id}/return |
purchasing:write |
Return received goods to the supplier. Destocks each line at the current weighted-average cost and posts one entry: Cr Inventory, Dr Accounts Payable for the part already billed (debit memo), Dr Goods Received Not Invoiced for the part not yet billed, and reverses the bills' price variance. The billed part becomes a vendor credit (vendor_credit, VC-####, on the same entry, so nothing posts twice) that A/P aging nets against the vendor; next_actions apply it to the order's open bills (POST /api/vendor-credits/{id}/apply) or record the vendor's refund. Never also POST /api/vendor-credits for a return: that debits A/P a second time. Send rma_number (the supplier's RMA) and optionally customer_return_id. Returns { return, vendor_credit, next_actions }; 409 account_roles_missing. |
POST /api/purchase-orders/parse |
purchasing:write |
Turn a sentence ("PO to Northwind for 20 cases of cleaner at $18") into an unsaved purchase-order draft with matched vendor, products and expense accounts; nothing is written. Create it with POST /api/purchase-orders, which needs a warehouse unless drop-ship. To record a service purchase or a vendor invoice, use POST /api/bills instead. Returns { draft }. |
/api/purchasing 3
| Operation | Scope | What it does |
|---|---|---|
GET /api/purchasing/backorders |
purchasing:read |
The standalone backorder view: submitted / partially_fulfilled sales-order lines still owed stock (quantity_ordered > quantity_fulfilled), grouped by product+warehouse and allocated FIFO against current on-hand so an operator can see which oldest orders could ship now versus what still needs a PO. This is the persistent counterpart to the receipt-time digest (see the PO receive route) -- the digest frames allocation on a single receipt's quantity, this frames it on current stock. Optional ?product_id / ?warehouse_id scope it. Advisory only: no stock is reserved or held. |
GET /api/purchasing/reorder-suggestions |
purchasing:read |
Products below their reorder point with suggested order quantities, grouped by preferred vendor (unassigned last). Optional ?warehouse_id (one warehouse) or ?location_id (that site's warehouses; warehouse_id wins). Turn the picks into draft purchase orders with POST /api/purchasing/reorder-suggestions/purchase-orders. |
POST /api/purchasing/reorder-suggestions/purchase-orders |
purchasing:write |
Create draft purchase orders from selected reorder-suggestion lines, one draft per (vendor, warehouse); nothing is submitted. Lines without a vendor come back under rejected. Returns { created: [{ vendor_id, warehouse_id, purchaseOrder }], rejected, errors }, 201 when at least one order was created. |
/api/recurring-bills 7
| Operation | Scope | What it does |
|---|---|---|
GET /api/recurring-bills |
ledger:read |
List recurring-bill templates (vendor joined, next_execution_date, is_active). generator says whether bills are made automatically and when; POST /api/recurring-bills/{id}/generate makes a due run now. |
POST /api/recurring-bills |
ledger:write |
Create a recurring-bill template (vendor, lines, schedule). Each run creates a draft A/P bill for the owner to approve; nothing is paid. Line account_id (or account_code) must be an expense, asset or liability account in this business (400 with line_accounts otherwise); a line without one debits the vendor's default expense account when the bill is approved (line_accounts debits vendor_default); with no vendor default the line is unresolved and the answer adds warning unresolved_line_account with next_actions (PATCH the template line with account_code, or PATCH /api/vendors/{id} { default_expense_account_code }). end_date before start_date is 400. The schedule is start_date and each date after it by the rule. When start_date is past, the first run is the first schedule date on or after today (today included), and the answer adds warning past_dates_not_generated { dates, count, total, ask_owner, next_actions } for the dates before today; backfill: true (here, or later with PATCH) makes a draft bill for each of those dates instead, answered in backfill { backfilled_dates, generated }. next_run { date, lines, total, line_accounts } is the first bill; generator says whether runs are made automatically and when; POST /api/recurring-bills/{id}/generate makes a due run now. GET /api/recurring-bills/{id}/schedule previews the next run dates. Any key not listed in the body is a 400 with a did-you-mean hint: next_execution_date is computed from start_date and the schedule, so set start_date (the first run is the first schedule date on or after today) and, for dates already passed, backfill: true drafts them. |
DELETE /api/recurring-bills/{id} |
ledger:write |
Delete a template (cascade removes its lines and executions). |
GET /api/recurring-bills/{id} |
ledger:read |
One recurring-bill template with its lines, its generated-bill history (executions) and next_run { date, lines, subtotal, tax_amount, total, line_accounts }: line_accounts says what each line debits (account, gr_ir for a product line, vendor_default for the vendor's default expense account, unresolved when neither; warning unresolved_line_account then). generator says whether bills are made automatically and when; POST /api/recurring-bills/{id}/generate makes a due run now. |
PATCH /api/recurring-bills/{id} |
ledger:write |
Update a recurring-bill template; is_active false pauses it. lines replace all its lines (account_id or account_code, an expense, asset or liability account). Returns next_run and line_accounts; a line left with no account while the vendor has no default expense account adds warning unresolved_line_account. Bills already generated are not changed. A schedule change before the first run re-plans it from start_date: a past start_date starts at the first schedule date on or after today and adds warning past_dates_not_generated. backfill: true makes a draft bill for every schedule date from start_date up to today that has none (answered in backfill { backfilled_dates, generated }); nothing is approved or paid. Any key not listed in the body is a 400 with a did-you-mean hint: next_execution_date is computed from start_date and the schedule, so set start_date (the first run is the first schedule date on or after today) and, for dates already passed, backfill: true drafts them. |
POST /api/recurring-bills/{id}/generate |
ledger:write |
Make the run of a recurring bill that is due (next_execution_date today or earlier) now, as a draft bill dated on that date for the owner to approve (never paid), instead of waiting for the daily generator. Nothing is made before its date: when next_execution_date is later than today it answers generated [] with the next date. Safe to repeat: a date is generated once, and each run moves next_execution_date on. catch_up: true makes every missed date up to today, oldest first (at most 60); without it one run. Schedule dates before the template's first run (a past start_date) are not made by catch_up: the answer then names them with a next_action PATCH { backfill: true }, which drafts them. Answer: generated [{ execution_date, status created|skipped|failed, draft_id, draft_path, reason }], next_execution_date, still_due, message, next_actions. A closed month or a finished template is skipped like the daily generator does; a failed run stops and keeps the date. 409 inactive for a paused template (next_actions resumes it); 409 finished for one that made all its runs (occurrence_count reached or past end_date), with next_actions to raise occurrence_count or extend end_date and resume. |
GET /api/recurring-bills/{id}/schedule |
ledger:read |
Preview a recurring-bill template's next run dates (count, default 6), using the same rule the generator uses: day_of_month 31 falls on the last day of shorter months (Feb 28). Stops at end_date and the remaining occurrence_count. Each run creates a draft A/P bill for the owner to approve. is_active false means paused: the dates apply once resumed. next_runs [{ date, total }] lists each run; per_run { lines, subtotal, tax_amount, total, line_accounts } is what every run bills and what each line debits (vendor_default = the vendor's default expense account; unresolved adds warning unresolved_line_account). generator says whether runs are made automatically; POST /api/recurring-bills/{id}/generate makes a due run now. |
/api/recurring-entries 7
| Operation | Scope | What it does |
|---|---|---|
GET /api/recurring-entries |
ledger:read |
List recurring journal entry templates (next_execution_date, is_active). generator says whether drafts are made automatically and when; POST /api/recurring-entries/{id}/generate makes a due run now. |
POST /api/recurring-entries |
ledger:write |
Create a recurring journal entry template (balanced lines naming account_id or account_code, schedule). Each run creates a draft journal entry for the owner to review and post; nothing posts on its own, whatever auto_post says (agents leave auto_post false). Lines must balance; end_date before start_date is 400; an unknown code is 400 unknown_account_code. The schedule is start_date and each date after it by the rule. When start_date is past, the first run is the first schedule date on or after today (today included), and the answer adds warning past_dates_not_generated { dates, count, total, ask_owner, next_actions } for the dates before today; backfill: true (here, or later with PATCH) drafts an entry for each of those dates instead (none is posted), answered in backfill { backfilled_dates, generated }. next_run { date, lines, total } is the draft the first run makes. generator in the response says whether runs are made automatically and when; POST /api/recurring-entries/{id}/generate makes a due run now. GET /api/recurring-entries/{id}/schedule previews the next run dates (day_of_month 31 falls on Feb 28). Warning recurring_entry_depreciates_asset: a line credits the accumulated depreciation account of an asset that has its own depreciation schedule, so both would book the month; keep one (the template is still created). |
DELETE /api/recurring-entries/{id} |
ledger:write |
Delete a recurring entry (cascade deletes lines and executions). |
GET /api/recurring-entries/{id} |
ledger:read |
One recurring journal entry template with its lines, next_run { date, lines, total } (the draft the next run makes) and executions: each run's execution_date, status (created, posted once its draft is posted, skipped, failed), draft_entry_id, posted_entry_id and entry_status. generator says whether drafts are made automatically; POST /api/recurring-entries/{id}/generate makes a due run now. |
PATCH /api/recurring-entries/{id} |
ledger:write |
Update a recurring journal entry template; is_active false pauses it, lines replace all its lines (must balance). Entries already drafted are not changed. A schedule change before the first run re-plans it from start_date: a past start_date starts at the first schedule date on or after today and adds warning past_dates_not_generated. backfill: true drafts an entry for every schedule date from start_date up to today that has none (answered in backfill { backfilled_dates, generated }); nothing is posted. Returns next_run { date, lines, total }. |
POST /api/recurring-entries/{id}/generate |
ledger:write |
Make the run of a recurring journal entry that is due (next_execution_date today or earlier) now, as a draft journal entry for the owner to post, instead of waiting for the daily generator. Nothing is made before its date: when next_execution_date is later than today it answers generated [] with the next date. Safe to repeat: a date is generated once, and each run moves next_execution_date on. catch_up: true makes every missed date up to today, oldest first (at most 60); without it one run. Schedule dates before the template's first run (a past start_date) are not made by catch_up: the answer then names them with a next_action PATCH { backfill: true }, which drafts them. Answer: generated [{ execution_date, status created|skipped|failed, draft_id, draft_path, reason }], next_execution_date, still_due, message, next_actions. A closed month or a finished template is skipped like the daily generator does; a failed run stops and keeps the date. 409 inactive for a paused template (next_actions resumes it); 409 finished for one past its end_date, with next_actions to extend end_date and resume. |
GET /api/recurring-entries/{id}/schedule |
ledger:read |
Preview a recurring journal entry's next run dates (count, default 6), using the same rule the generator uses: day_of_month 31 falls on the last day of shorter months (Feb 28). Stops at end_date. Each run creates a draft journal entry the owner reviews and posts. is_active false means paused: the dates apply once resumed. next_runs [{ date, total }] lists each run; per_run { lines (account_id, account_code, account_name, debit, credit), total } is the draft every run makes (the template's lines, the same each run). generator says whether runs are made automatically; POST /api/recurring-entries/{id}/generate makes a due run now. |
/api/recurring-invoices 7
| Operation | Scope | What it does |
|---|---|---|
GET /api/recurring-invoices |
ledger:read |
List recurring-invoice templates (customer joined, next_execution_date, occurrences_generated, is_active). generator says whether invoices are made automatically and when; POST /api/recurring-invoices/{id}/generate makes a due run now. |
POST /api/recurring-invoices |
ledger:write |
Create a recurring-invoice template (customer, lines, schedule). Each run creates a draft invoice; nothing is emailed or texted to the customer until someone sends it with POST /api/invoices/{id}/send. A line names its income account with account_id or account_code (an income account; 400 unknown_account_code or invalid_line_account); with neither, a product_id line posts to the product's income_account_id, else the revenue role account. unit_price left out resolves at each run (customer price list, else the product's sell_price); a number is fixed. The response's next_run { date, lines (unit_price, amount, account_id), subtotal, tax_amount, total, line_accounts } is the invoice the first run makes at today's prices; check line_accounts. end_date before start_date is 400. The schedule is start_date and each date after it by the rule. When start_date is past, the first run is the first schedule date on or after today (today included), and the answer adds warning past_dates_not_generated { dates, count, total, ask_owner, next_actions } for the dates before today; backfill: true (here, or later with PATCH) makes a draft invoice for each of those dates instead (none is sent), answered in backfill { backfilled_dates, generated }. generator says whether runs are made automatically and when; POST /api/recurring-invoices/{id}/generate makes a due run now. GET /api/recurring-invoices/{id}/schedule previews the next run dates (day_of_month 31 falls on Feb 28). Any key not listed in the body is a 400 with a did-you-mean hint: next_execution_date is computed from start_date and the schedule, so set start_date (the first run is the first schedule date on or after today) and, for dates already passed, backfill: true drafts them. |
DELETE /api/recurring-invoices/{id} |
ledger:write |
Remove a template (lines cascade). Generated invoices are untouched. |
GET /api/recurring-invoices/{id} |
ledger:read |
One recurring-invoice template with its lines, its generated-invoice history (executions) and next_run: the draft invoice the next run makes at today's prices { date, lines (unit_price, amount, account_id), subtotal, tax_amount, total, line_accounts }. line_accounts names the income account each line posts to (posts_to line_account, revenue_role, or revenue_default when no account holds the revenue role). generator says whether invoices are made automatically and when; POST /api/recurring-invoices/{id}/generate makes a due run now. |
PATCH /api/recurring-invoices/{id} |
ledger:write |
Update a recurring-invoice template; is_active false pauses it. lines replace all the template's lines (each may name its income account with account_id or account_code; with neither, a product line posts to the product's income_account_id, else the revenue role account). Invoices already generated are not changed: PATCH /api/invoices/{id} for those. Returns the template with next_run (the next invoice at today's prices, with line_accounts). A schedule change before the first run re-plans it from start_date: a past start_date starts at the first schedule date on or after today and adds warning past_dates_not_generated. backfill: true makes a draft invoice for every schedule date from start_date up to today that has none (answered in backfill { backfilled_dates, generated }); nothing is sent. Any key not listed in the body is a 400 with a did-you-mean hint: next_execution_date is computed from start_date and the schedule, so set start_date (the first run is the first schedule date on or after today) and, for dates already passed, backfill: true drafts them. |
POST /api/recurring-invoices/{id}/generate |
ledger:write |
Make the run of a recurring invoice that is due (next_execution_date today or earlier) now, as a draft invoice dated on that date (not sent; POST /api/invoices/{id}/send sends it), instead of waiting for the daily generator. Nothing is made before its date: when next_execution_date is later than today it answers generated [] with the next date. Safe to repeat: a date is generated once, and each run moves next_execution_date on. catch_up: true makes every missed date up to today, oldest first (at most 60); without it one run. Schedule dates before the template's first run (a past start_date) are not made by catch_up: the answer then names them with a next_action PATCH { backfill: true }, which drafts them. Answer: generated [{ execution_date, status created|skipped|failed, draft_id, draft_path, reason }], next_execution_date, still_due, message, next_actions. A closed month or a finished template is skipped like the daily generator does; a failed run stops and keeps the date. 409 inactive for a paused template (next_actions resumes it); 409 finished for one that made all its runs (occurrence_count reached or past end_date), with next_actions to raise occurrence_count or extend end_date and resume. |
GET /api/recurring-invoices/{id}/schedule |
ledger:read |
Preview a recurring-invoice template's next run dates (count, default 6), using the same rule the generator uses: day_of_month 31 falls on the last day of shorter months (Feb 28). Stops at end_date and the remaining occurrence_count. Each run creates a draft invoice; nothing is emailed or texted until it is sent with POST /api/invoices/{id}/send. is_active false means paused: the dates apply once resumed. next_runs [{ date, total, line_accounts }] and per_run { lines (unit_price, amount, account_id), subtotal, tax_amount, total, line_accounts } show what each run bills and the income account each line posts to, at today's prices (a unit_price left off the template resolves at each run from the customer's price list, else the product's sell_price). generator says whether runs are made automatically; POST /api/recurring-invoices/{id}/generate makes a due run now. |
/api/reports 19
| Operation | Scope | What it does |
|---|---|---|
GET /api/reports/1099-k-summary |
ledger:read |
Card processor 1099-K tie-out for calendar ?year=YYYY: per processor (square, stripe, paypal, venmo, shopify, toast, clover...) from the year's bank rows and their entries, posted and drafts separately: gross_sales (income booked from payouts plus the sales tax collected in them, as box 1a includes tax; sales_tax_in_payouts is that tax; processor-payout books each at gross), fees_in_payouts, net_deposited, payout_count, chargebacks, refunds, other_debits (reversals, adjustments) and separate_fees, plus payouts_booked_net (income = the deposit, no fee line: gross is short by the fees; each has the processor-payout next_action, after a reverse when posted), payouts_to_other_accounts (a payout booked to A/R or equity), unbooked and ignored rows. form_1099k is the box 1a the owner recorded with PUT /api/reports/1099-k-summary/box-1a; difference = box 1a - posted gross_sales, difference_with_drafts, and explanation says in plain words why they can differ. ties_out, totals, ask_owner (which processors' 1099-K to ask for) and next_actions. Chargebacks and refunds are listed apart because 1099-K gross ignores them. Entries carry the processor (journal_entries.processor; the general ledger's counterparty shows it). 1099-NEC/MISC vendors are GET /api/reports/1099-summary. |
PUT /api/reports/1099-k-summary/box-1a |
ledger:write |
Record the box 1a (gross payments) from a card processor's 1099-K for a calendar year: { year, processor, gross_amount, transaction_count?, note? }. processor is the name GET /api/reports/1099-k-summary uses ("square", "stripe"; "Square Inc" works). Sending it again for the same year and processor replaces it. Changes no ledger amount. Returns form, processor (that processor's tie-out with difference and explanation), ties_out and ask_owner for the year, and a next_action GET for the whole summary. |
GET /api/reports/1099-summary |
ledger:read |
1099 summary for a calendar year (?year=YYYY, default this year; &format=csv for a file; &vendor_id= for one vendor: its row, excluded card payments, warnings and questions, with vendor_scope saying so; an unknown parameter is a 400): per 1099 vendor, bill payments made in the year (billPaid, billPaymentCount) plus payments with no bill (directPaid: posted bank, cash or check entries linked to the vendor with vendor_id, e.g. Zelle, check or ACH straight from the bank) plus priorSystemPaid (old-system payments before a mid-year switch, recorded with POST /api/vendors/{id}/prior-1099-payments) less cash the vendor refunded (vendor credit refunds), and whether the total reaches the reporting threshold for that payment year (threshold: $600 for 2025 and earlier, $2,000 for 2026 under the One Big Beautiful Bill Act, inflation-adjusted from 2027; thresholdNote says which, and when a later year's figure is provisional). meetsThreshold, digest.crossed and digest.approaching (80% of it) use that threshold. Card payments (payment_method card, or paid from a credit card account) and payment-app payments (a linked entry whose description names Venmo, PayPal or Cash App) are reported by the processor on 1099-K, so they are left out of totalPaid and listed in excludedCardPayments with reason card or payment_app (row cardPaid). unattributed_contractor_expense (null when none) is posted expense on contractor pay accounts (names like Contractors, Subcontractors, Contract Labor; a name that pairs contractor with goods such as Contractor Supplies, Tools or Materials is not one unless it also names labor or services) tied to no vendor and no bill: ask the owner who was paid and link each entry with PUT /api/ledger/{id}/vendor. Contractor lines inside an old system's opening or year-to-date summary entry (a line on Opening Balance Equity or retained earnings, or income and expense against owner equity) are left out of it and listed in opening_contractor_expense (null when none) with prior_system_recorded: those reach a 1099 only through POST /api/vendors/{id}/prior-1099-payments, never by linking the summary entry to a vendor. Each row has missingTaxId (no usable tax id: true for a blank tax id, a placeholder like 00-0000000, or an invalid one, so it is true alongside invalidTaxId even though a tax id is on file; the CSV leaves a blank or placeholder Tax ID blank and says Yes under Needs W-9) and invalidTaxId (a tax id is on file but unusable: not 9 digits, e.g. 12 or a last-4, or 9 digits the IRS never issues, e.g. 00-0000256; a W-9 TIN is EIN NN-NNNNNNN or SSN NNN-NN-NNNN); warnings[] has an invalid_tax_id entry (vendor_id, tax_id, ask_owner, PATCH /api/vendors/{id}) for each such vendor and a shared_tax_id entry (tax_id, vendors, ask_owner, a PATCH /api/vendors/{id} per vendor) for each tax id two or more 1099 vendors share, usually a mistyped TIN; digest.needsW9 lists 1099 vendors paid this year with no usable tax id (invalidTaxId true on those with a malformed one) (only those with remainingToThreshold <= 0 need a 1099; for the rest a W-9 is ahead of time). coverage.coversFullYear false means the books start mid-year (coverage.note says what to record). asks[] holds the questions for the owner (unattributed contractor pay, W-9s, old-system totals, shared tax ids), one per entry and most pressing first; ask_owner is asks[0]: ask it, then the next once it's answered. next_actions has the calls. Vendor credits applied to bills are not subtracted; they already reduced what was paid. |
GET /api/reports/ap-aging |
ledger:read |
Accounts payable aging as of a date: bills owed, bucketed by days past due (current, 1-30, 31-60, 61-90, 90+) with totals per bucket and per vendor. ?as_of=YYYY-MM-DD (default the business's today in its time zone); only bills issued and payments dated on or before it count. Applied vendor credits lower bill balances; unapplied vendor credit appears as kind 'credit' records with a negative balance and nets the vendor's oldest bucket first (90+ down to current; credit beyond what the vendor is owed stays netted in current, lowering currentTotal and total, because unused vendor credit sits on the A/P account itself, so excessCredits is always empty on A/P; nettedByBucket shows where; totals.unappliedCredits), so overdueTotal is net of credit and overdueGross is before it. vendor_id narrows the report to one vendor: 'how much do I owe this vendor' is totals.total with ?vendor_id=; a vendor_id that is not a vendor of this business is a 404 vendor_not_found, never an empty report. An unknown parameter is a 400. format=csv returns one row per bill or credit as a file. |
GET /api/reports/ap-control |
ledger:read |
Reconcile a payable account against its open bills in one call, as of today: what to run when GET /api/setup/status payables_detail.ap_account_check (or opening_ap_check) shows a difference. Default account: Accounts Payable; ?account_code= or account_id= picks another (an opening A/P account an opening bill clears). Returns ledger_balance, expected (open bills less unapplied vendor credit, as A/P aging counts them) and difference, split exactly into vendors_difference + unlinked_total - opening_bills_total: unlinked_entries (posted entries on the account no bill, payment, vendor credit, credit application or refund stands for, e.g. a manual entry or an old vendor return debit memo; first 50, unlinked_count all), vendors (each vendor whose document entries on the account differ from their open bills and credits), bills_not_on_account (open bills whose own entry put nothing on the account, with posted_to: where it went) and opening_bills (opening bills that clear this account and post no entry of their own). note explains how to correct: a draft journal entry for the owner, never an edit to a posted entry. 404 account_not_found for an unknown account; 400 not_payable_account for an account that is not a payable (a bank, asset, income or expense account), naming the A/P account to use. Read-only. |
GET /api/reports/ar-aging |
ledger:read |
Accounts-receivable aging: sent invoices' outstanding balances (total less payments, applied deposits and applied credit memos) bucketed current / 1-30 / 31-60 / 61-90 / 90+, totalled per bucket and per customer. Unapplied credit memo credit, and open customer credit (money a customer paid that no invoice used yet, reference 'Customer credit'; GET /api/customers/{id}/credits), appear as kind 'credit' records with a negative balance and net that customer's own buckets, oldest first (90+ down to current; creditsApplied says so, nettedByBucket shows where each went), never other customers', so overdueTotal and currentTotal are both net of credit and overdueGross is past due before credit (what GET /api/invoices?overdue=true sums): totals.unappliedCredits sums what was netted, and credit beyond what its customer owes (a prepaid package or retainer from a customer with no open invoice) is not in totals.total but in totals.excessCredits and excessCredits [{ partyId, partyName, amount }] (customer prepayments, held in Customer Deposits). customer_id narrows the report to one customer: 'how much does this customer owe me' is totals.total with ?customer_id= (parties[0] is that customer; an empty report means they owe nothing). A customer_id that is not a customer of this business is a 404 customer_not_found (a CRM contact or company id says so and gives linked_customer_id), never an empty report. Always as of today: there is no as_of, and an unknown parameter is a 400. A migrated invoice (import-open) whose old system showed a larger original total carries originalTotal and settledBeforeSwitch (paid there before the switch); total is what carried over. format=csv returns one row per invoice or credit as a file. brief=true drops the per-invoice records (records_count says how many) and keeps buckets, parties, totals and excessCredits: the small answer to 'who owes me'. |
GET /api/reports/balance-sheet |
ledger:read |
Balance sheet as of a date: asset, liability and equity accounts with balances, retained earnings (income minus expense to date, split into prior_years_net_income and current_year_net_income at current_year_start, the fiscal year start; the rollover is virtual, no closing entry is posted), totals and balanced (assets = liabilities + equity). ?as_of=YYYY-MM-DD (default today); include_drafts=true previews unposted drafts; format=csv returns it as a file. ?basis=cash|accrual (default: the business's accounting_method): on cash an approved bill still unpaid is neither in Accounts Payable nor in earnings; on accrual open invoices are in Accounts Receivable (and their sales tax in Sales Tax Payable) and in earnings. The posted ledger is not changed; basis says which basis the numbers are on and what it changed. |
GET /api/reports/budget-vs-actual |
budgets:read |
Budget vs actual for one budget over start..end (inclusive, start on or before end). report.rows has one row per income or expense account: budgeted, actual (posted entries only), variance_amount (actual - budgeted), variance_pct and favorable; report.totals has income, expense and net. digest.flags lists the accounts worth raising with the owner (over or under plan, off pace), most severe first. Money is rounded to 2 decimals. |
GET /api/reports/cash-flow |
ledger:read |
Cash flow statement for a period (indirect method): "where did my cash go". operating, investing and financing each have lines and a total (amount > 0 = cash in, < 0 = cash out). operating starts with a "Net income" line (code "", equal to netIncome; the income and expense accounts behind it are in operating.net_income_detail), then the working-capital changes and non-cash add-backs (receivables, payables, accumulated depreciation), e.g. net income 3,417.13 + increase in A/P 899.99 = 4,317.12; investing and financing list the accounts that moved cash. ?basis=cash|accrual works as on GET /api/reports/profit-and-loss (default: the business's accounting_method), so netIncome equals the P&L's netIncome for the same dates and basis; the basis block says which basis and what it moved (on cash basis an approved, unpaid bill is neither an expense nor an A/P increase; cash totals are the same on either basis). noncash lists posted entries that moved equipment, a loan or owner money without moving cash (e.g. equipment bought on a credit card: Dr equipment / Cr card); they are left out of the sections (any expense on them is added back in operating as "Non-cash expenses and income"), and the cash leaves when the card or bill is paid (operating) or the loan is paid (financing). beginningCash + netChangeInCash = endingCash. cashAccounts lists the cash/bank accounts that moved in the period with beginning and ending balances; cash_accounts_unchanged counts and sums the rest (?cash_accounts=all lists every one), so endingCash ties to those accounts on GET /api/reports/balance-sheet?as_of=<to>. reconciles=false (with an unclassified amount) only when the ledger itself is out of balance. Accounts are bucketed by subtype (bank/cash = cash; fixed_asset = investing; loan and equity = financing; the rest operating, credit cards and accumulated depreciation included), else by code range. ?from=YYYY-MM-DD&to=YYYY-MM-DD; format=csv returns it as a file. |
GET /api/reports/cash-summary |
ledger:read |
Money in and out of the bank for a period, by what it was, in one call: "what came in and went out this month". money_in and money_out each have total and by_kind [{ kind, label, amount, accounts }] (fees_withheld_from_deposits: processor fees inside deposits booked at gross, counted in both totals but never on the statement; the summary gives the bank's in and out without them; bank_shows { money_in, money_out } is what the statement shows): income (earned), client_prepayments (held for clients in Customer Deposits, not income; a prepayment received in the period and applied to an invoice by to counts under income instead, listed in applied_prepayments { amount, credits [{ customer, received_on, applied [{ date, amount, invoice_number, counted_as }] }] }, null when none), owner (money the owner put in / draws they took out), expenses (costs), transfers (to and from the business's other bank accounts), card_payments (paid toward your card: a bank payment to a credit card account), invoice_payments, bills, loans, sales_tax, payroll (payroll taxes and withholdings paid: a payment to a Payroll Liabilities account), opening_balance, equipment, unsorted (the catch-all: not sorted yet), other. summary is a line to tell the owner (came in 540.00 (60.00 earned, 480.00 held for clients), went out 320.17 (70.17 costs, 250.00 owner draws); 2,219.83 left). opening_balance + net_change = closing_balance, and accounts lists each account's opening and closing balance. Each account also has bank_check { statement_ends_on, bank_balance, books_balance, difference, starting_balance_missing } when a statement imported for it ends in the period (else null). When the books differ from the bank, summary gives the net change and the bank's figure instead of 'left at the end' (never quote closing_balance as money in the bank then; bank_balance_note says so); when a completed reconciliation of that statement left lines outstanding that make up the whole difference (a refund not through the bank yet), bank_check.outstanding_items { reconciliation_id, items [{ date, amount, description }] } lists them and summary says nothing is missing (never draft an entry for them); with no starting balance recorded, bank_check adds starting_balance_as_of, implied_starting_balance and an optional ask_owner (ask only if the owner wants the books to match the bank). A starting balance drafted against Opening Balance Equity and not approved yet (dated before from, as the statement import drafts it) is not missing: bank_check.starting_balance_drafted { draft_ids, as_of, amount, counted } says so and there is no ask_owner (never ask or draft it again); include_drafts=true counts it in opening_balance. Rows drafted to the catch-all (Uncategorized Income / Expense) are kind unsorted, labeled 'not sorted yet', never earned or costs; unsorted_note says to ask the owner what they were. Posted entries by default; drafts_waiting says what unapproved drafts would add, and include_drafts=true counts them. ?from&to (default this month to date), account_id for one bank or card account (a card's balance is what is owed; money out = charges). Only what the bank moved counts: a line booked inside a payment that the bank did not pay (a payroll run's withholdings and employer taxes credited to Payroll Liabilities, the open part of a bill) is not money in; it comes off that payment's costs and is listed in booked_not_paid { amount, accounts, note } (null when none), and the later tax deposit or bill payment counts it out once. Omit account_id for every bank and cash account together; money between two of them is left out. An entry and its reversal both in the period (a voided payment, a recategorized charge) moved no money and are left out together; reversed_left_out counts them. |
GET /api/reports/commissions |
ledger:read |
Sales-rep commissions, not a ledger report: commission owed per rep on invoices paid between ?start= and ?end= (YYYY-MM-DD), at each rep's flat rate on the pre-tax subtotal, with per-invoice lines and per-rep totals. Nothing is booked or paid; a 403 means the caller cannot manage users. |
GET /api/reports/daily |
reports:read |
Website traffic, not a financial report: the business's daily web-analytics email for one day (default yesterday in ?timezone=, default America/Los_Angeles). Sections so far: web_analytics (visitors, page views, applications and AI crawler hits across every site, per-site lines with day-over-day change, top sources, and sites whose nightly rollup did not report). ?day=YYYY-MM-DD, ?sections=web_analytics, ?title=. Returns { report: { day, timezone, sections }, email: { subject, text, html } }; send the email with POST /api/email/send. |
GET /api/reports/general-ledger |
ledger:read |
General ledger for a period: per account (ordered by code), opening_balance as of the day before from, every posted line dated in the period, ordered by date then when the entry was recorded (created_at), so a void follows what it voids (date, created_at, entry_id, entry and line description, counterparty, debit, credit) with running_balance starting from the opening balance, total_debit/total_credit and closing_balance. Balances are positive on the account's normal side (normal_balance: debit for assets and expenses, credit for liabilities, equity and income), so a bank account's closing_balance matches its balance-sheet amount. ?from=YYYY-MM-DD&to=YYYY-MM-DD, optional account_id or account_code for one account (404 account_not_found when it is not in the chart); include_drafts=true previews unposted drafts; format=csv returns every line as a file. Accounts with no opening balance and no activity are left out. These are the posted ledger's numbers (bills as expense when approved, invoices as income when paid), while the P&L, trial balance and balance sheet default to the business's accounting_method, so on a cash-basis business an expense account can show more here than on the trial balance. basis says which: basis.differing_accounts gives each differing account's ledger and basis numbers, basis.adjustments.items the bills and invoices behind it (with account_id or account_code, only those that touch that account). ?basis=cash|accrual adds that basis's adjustments as lines (status basis_adjustment, entry_id basis:<bill|invoice>:<id>, never posted) so the closing balances tie to GET /api/reports/trial-balance?basis= the same. |
GET /api/reports/inventory-aging |
inventory:read |
Inventory module, not a financial report: how long stock has sat since it last shipped, per product and warehouse, in 0-30/31-60/61-90/90+ day buckets, flagging slow movers past ?slowMovingDays= (default 90). Valued at on-hand x weighted-average cost from inventory movements, so totals.totalValue can differ from the Inventory account on the trial balance; for the books use GET /api/reports/trial-balance. Optional ?warehouseId=. |
GET /api/reports/margin-by-job |
ledger:read |
Margin by job (project), job costing and job profitability: revenue minus direct costs (expense lines tagged with project_id) per job, with totals. Same params as the P&L: ?from=YYYY-MM-DD&to=YYYY-MM-DD (both, or neither for all time), location_id, include_drafts=true to preview unposted drafts (default false: posted ledger only). ?basis=cash|accrual picks the basis (default: the business's accounting_method), as on the P&L: accrual counts each sent invoice's job lines as revenue on its issue date, paid or not (so mid-job the report shows everything invoiced); cash counts invoices when paid and each approved bill's job expense on the dates it was paid. The basis block says which basis the numbers are on, what it changed against the posted ledger (adjustments: revenue, direct_costs and the invoices and bills behind them) and a note to quote. Job costing: tag draft entry lines and invoice lines with project_id (POST /api/pm/projects makes the job). |
GET /api/reports/profit-and-loss |
ledger:read |
Profit & loss for a period: income and expense accounts with amounts, totals and netIncome; expenses lists every expense account, and cost_of_goods_sold (accounts with subtype cogs), operating_expenses and grossProfit give the gross margin split, from the posted ledger (include_drafts=true previews unposted drafts). ?from=YYYY-MM-DD&to=YYYY-MM-DD, optional location_id; format=csv returns it as a file. ?basis=cash|accrual picks the basis (default: the business's accounting_method): cash counts each approved bill as an expense on the dates it was paid, not when approved; accrual counts each sent invoice as income on its issue date, paid or not. The posted ledger (invoices income when paid, bills expense when approved) is not changed: the basis block says which basis the numbers are on, what it changed (adjustments: income, expenses and the bills and invoices behind them) and a note to quote. |
GET /api/reports/sales-tax |
ledger:read |
Sales tax liability for filing a return: per month or quarter, gross sales, exempt sales (exempt_sales_by_reason: exempt_customer, non_taxable_lines, no_tax_charged), taxable sales (net of discounts and refunds), tax collected on invoice payments and on any other posted sale that credits income and sales tax payable together (a card processor payout booked with processor-payout sales_tax counts its income as taxable card sales, less its non_taxable_sales, which are non_taxable_lines like any income line marked taxable: false; one booked without sales_tax is no_tax_charged), tax refunded on credit memo refunds and returns, net tax, tax remitted (other debits to sales tax payable), adjustments, and the liability at the start and end of each period, with the invoice, refund and remittance entries behind each number (include_detail=false omits them). ?from=YYYY-MM-DD&to=YYYY-MM-DD&period=month|quarter; dates snap to whole periods (period_aligned: true and period_note when the report's from and to differ from requested). Posted ledger, cash basis. With no sales tax payable account it returns a note and next_actions to map the role. Record a remittance as a draft debiting the sales tax account and crediting cash (POST /api/entries/draft): when tax is owed at the end, next_actions carries that draft's body for the closing liability (add transaction_date, use the amount actually paid). A draft that pays more than is owed comes back with warning sales_tax_overpaid. other_sales_tax_accounts lists other sales tax liabilities (subtype sales_tax, such as one imported from Xero or QuickBooks) with a nonzero posted balance at the report end, either sign (balance < 0 is a debit balance: a tax payment was booked there instead of the sales tax account, and the move draft clears it against the sales tax account): that tax is not in these numbers, so warnings carries sales_tax_in_other_accounts with ask_owner, and next_actions leads with the draft that moves it into the sales tax account. |
GET /api/reports/trial-balance |
ledger:read |
Trial balance as of a date: every account with a non-zero balance in its debit or credit column, column totals and balanced. The quickest way to spot a mis-posted or uncleared account (e.g. a GR/IR balance with nothing to clear). ?as_of=YYYY-MM-DD (default today); include_drafts=true previews unposted drafts. ?account_code= or account_id= checks one account: rows holds only its row (empty at a zero balance), account always gives its debit and credit, basis.adjustments counts only that account (account_change: what the basis moves on its balance, debit minus credit, e.g. unpaid bills leaving Accounts Payable on cash basis; items[].account_change per document), and totalDebit/totalCredit/balanced stay the whole trial balance's (totals_scope all_accounts); 404 account_not_found when it isn't in the chart, 400 with map_group_id. ?basis=cash|accrual (default: the business's accounting_method) shows the balances on that basis without changing the posted ledger (cash: unpaid approved bills leave Accounts Payable and expenses; accrual: open invoices are in Accounts Receivable and income); basis says what it changed. ?map_group_id= (an account map from GET /api/account-map-groups) adds each row's external_code/external_name, external_rows (the trial balance rolled up in the map's codes, e.g. for an accountant on QuickBooks; accounts the map does not cover roll into one external_code UNMAPPED row last, so the external totals tie) and unmapped (the codes of those accounts; their rows say external_code null); 404 map_group_not_found for an unknown id. format=csv returns the trial balance as a file (code, account, type, debit, credit, totals); with map_group_id the file is the mapped trial balance instead (external code, external account, debit, credit, our account codes, and a Total row summing the rows shown; JSON carries those as external_total_debit / external_total_credit, next to the gross per-account totalDebit / totalCredit). |
GET /api/reports/year-end |
ledger:read |
Year-end package for ?year=YYYY (the fiscal year, named by the calendar year it ends in). checks report only and never block: year_ended, books_start_mid_year (the books start after the fiscal year start and the old system's year-to-date income and expenses are not recorded, so the tax summary is short), months_closed (all 12, less months before the books start date), unposted_drafts, bank_reconciled (every cash, bank, credit card and loan account through year end), vendors_1099 (vendors over the threshold have a tax ID), depreciation_posted (every asset through year end), owner_equity (contributions, draws, equity not negative) and processors_1099k (each card processor's 1099-K box 1a, recorded with PUT /api/reports/1099-k-summary/box-1a, ties to the gross card sales booked for the calendar year, and no payout is booked net; its details carry the ask_owner for the box 1a); each has ok, details, ids and fix (the call that fixes it). Also profit_and_loss, basis (?basis=cash|accrual, default the business's accounting_method: on cash a bill approved in the year and unpaid at year end is not deducted, on accrual a sent invoice is income on its issue date; the block says what it changed against the posted ledger and gives a note to quote; profit_and_loss, tax_summary and retained_earnings are on that basis), processors_1099k (ties_out, ask_owner, totals, per processor gross_sales, box_1a, difference; GET /api/reports/1099-k-summary has the detail), retained_earnings (prior_years_net_income and current_year_net_income, a virtual rollover with no closing entry) and tax_summary: the year's P&L on Schedule C lines (from the "Schedule C" account map, else suggested by subtype and name; source says which), meals on line 24b at 50%, gross income, total expenses, tentative net profit, unmapped accounts and coverage (from, to, books_start_date, complete, pre_start, warning: when complete is false the figures cover only the span since the books start, not the whole tax year). Seed the map with POST /api/account-map-groups/schedule-c. |
/api/route-planning 6
| Operation | Scope | What it does |
|---|---|---|
GET /api/route-planning/leads |
crm:read |
Selectable stops for the route planner: companies. The address belongs to the business, not to whoever works there, so a company is one visit however many people are on file, and the people come back on the stop as who to ask for. `unpromoted_people` counts contacts with no company -- they are not stops, they are companies nobody has created yet (see POST /api/crm/contacts/{id}/promote-to-company). Filters: pipeline_ids (union), search, tags. Same capability the dashboard, a chat tool, or a ticket assignee can call. |
GET /api/route-planning/leads/{kind}/{id} |
crm:read |
Quick-view detail for a single route-planning lead (a CRM contact or a company): core identity, resolved address, open deals, recent activity. The dashboard opens this in a modal; the same capability is callable from a chat tool or a ticket assignee. |
GET /api/route-planning/routes |
crm:read |
recent routes for the business. |
POST /api/route-planning/routes |
crm:write |
Build and persist a sales-visit route from selected contacts + a start location + a departure time. Returns the ordered stops with ETAs and a Google Maps multi-stop link. This is the interface-agnostic capability the dashboard, an AI chat tool, and ticket assignees all call. |
GET /api/route-planning/routes/{id} |
crm:read |
Field sales: one planned visit route with its stops in order. Returns { route }; an unknown id is 404, a malformed one 400. |
POST /api/route-planning/routes/{id}/stops/{stopId}/outcome |
crm:write |
Field sales: log what happened at a visited stop; it also lands on the contact or deal timeline. Returns { stop }; an unknown route or stop is 404, a malformed id 400. |
/api/sales-orders 15
| Operation | Scope | What it does |
|---|---|---|
GET /api/sales-orders |
sales:read |
List sales (goods) orders, newest first, each with billing_status and has_unbilled_shipment. Filters: status, customer_id, channel, warehouse_id, location_id, billing=unbilled. limit (max 500) and offset page the list; with either, the response adds count (total matching) and has_more. An unknown parameter is a 400. |
POST /api/sales-orders |
sales:write |
Create a sales order for goods a customer is buying, shipped from a warehouse. Each line names a product; a line without a description takes the product's name. It starts as a draft and nothing posts to the books: PATCH { status: "submitted" } confirms it and reserves the stock, POST /api/sales-orders/{id}/fulfill ships it (Dr Cost of Goods Sold / Cr Inventory), and POST /api/sales-orders/{id}/invoice bills what shipped. A submitted order over the customer's credit limit is refused unless credit_override with override_reason. For services or goods that are not stocked, use POST /api/invoices. Returns { salesOrder, next_actions }. |
DELETE /api/sales-orders/{id} |
sales:write |
Discard a DRAFT sales order outright (lines cascade). Order entry creates the draft as soon as a customer is picked, so abandoned attempts are a normal outcome and need a way to be thrown away rather than accumulating as clutter. Deliberately draft-only: a draft never reserved stock and was never confirmed to anyone, so there is nothing to reverse. A confirmed order is CANCELLED (which releases its reservation and keeps the record), never deleted -- PATCH { status: "cancelled" } is that path. The SO number it consumed is not returned to the sequence: numbers are issued once and never reused, so a gap is expected and correct. |
GET /api/sales-orders/{id} |
sales:read |
Full sales order: header, customer + warehouse, lines (with product), and every fulfillment event with its lines. |
PATCH /api/sales-orders/{id} |
sales:write |
Two shapes: - { status: "submitted" | "cancelled" } transitions the order (validated against the lifecycle; fulfilled/partially_fulfilled are fulfillment- driven, not manual targets). - header field edits (customer, warehouse, dates, notes) while still draft. `fulfilled`/`partially_fulfilled` are never set here -- they flow from fulfillment. |
GET /api/sales-orders/{id}/deposits |
sales:read |
List the customer deposits (prepayments) booked against this sales order. Returns { deposits }. |
POST /api/sales-orders/{id}/deposits |
sales:write |
Record a customer deposit / prepayment against an open sales order. Money collected BEFORE any invoice exists is booked as a liability (Dr Cash / Cr 2650 Customer Deposits) via recordCustomerDeposit -- deterministic, so it posts directly to the ledger. Not recognized as revenue: Phase 2 applies the deposit when the order is invoiced. GET /api/sales-orders/:id/deposits -- list the deposits booked against this order. |
POST /api/sales-orders/{id}/deposits/{depositId}/refund |
sales:write |
Refund the unapplied balance of a customer deposit (e.g. the order was cancelled before fulfillment) and mark it refunded. Reverses the receipt: Dr 2650 Customer Deposits / Cr Cash via refundCustomerDeposit. |
GET /api/sales-orders/{id}/drop-ship-suggestion |
purchasing:read |
Prefill payload for creating a purchase order from this sales order (the create-PO-from-SO flow, phase 7). Gathers the facts phase 5's suggestDropShip needs -- per line: the product's track_inventory flag and total on-hand summed across every warehouse, plus the SO's end customer -- and returns the lines still owed goods with a per-line drop-ship reason where the seller doesn't stock the product. Follows the drop-ship.ts contract: this handler GATHERS facts and SUGGESTS; it never mutates. The operator confirms the drop-ship linkage in the create-PO UI, which then POSTs /api/purchase-orders with is_drop_ship + the per-line source_sales_order_line_id links. When at least one line is a drop-ship candidate, only those lines are returned and `defaultDropShip` is true (a drop-ship PO is header-level all-or-nothing -- is_drop_ship and drop_ship_customer_id live on the PO, not the line). When none are, every open line is returned as a plain restock prefill (`defaultDropShip` false). |
POST /api/sales-orders/{id}/fulfill |
sales:write |
Ship part or all of a submitted sales order out of a warehouse (a draft order is submitted first with PATCH { status: "submitted" }). Each shipped line draws stock down at the product's average cost and posts Dr Cost of Goods Sold / Cr Inventory; the order becomes partially_fulfilled or fulfilled. Supports partial fulfillment and a per-shipment warehouse override. fulfilled_date (default the business's today in its own time zone) dates the shipment and its entry; a future date is a 400 future_date and a closed month a 409 closed_period. invoiceSuggestion previews the bill for what shipped and next_actions carries POST /api/sales-orders/{id}/invoice to draft it. 400 with code invalid_status, quantity_exceeds_remaining, unknown_order_line or insufficient_stock (issues names the line); 409 account_roles_missing. |
POST /api/sales-orders/{id}/invoice |
ledger:write |
Draft the invoice for a sales order's shipped, unbilled goods: each line bills the shipped quantity not yet invoiced at the order's unit price, keeps its product_id and posts to the product's income account (else the revenue role account), with the customer's terms and the business's sales tax. Optional body { issue_date } (YYYY-MM-DD, default the business's today in its own time zone; send the shipping day to keep the sale in the month its cost was booked). An open deposit on the order nets against it (applied_deposit). Cost of goods was booked at shipping, so the invoice records only the sale. Nothing is sent: line_accounts shows where each line posts, and next_actions carries the send and payment calls. 400 when nothing shipped remains unbilled. |
POST /api/sales-orders/{id}/lines |
sales:write |
Append lines to a draft sales order, priced from the order customer's price list and converted to base units like lines added at creation, then re-total the order. Draft orders only. Returns 201 { salesOrder } with the new totals. |
DELETE /api/sales-orders/{id}/lines/{lineId} |
sales:write |
Remove one line from a DRAFT sales order and re-total the order. Draft-only: a submitted order holds a stock reservation against its lines, so dropping one here would leave a phantom hold on stock nothing is going to ship. Removing a line from a confirmed order has to release that line's reservation, which is a different operation. |
GET /api/sales-orders/{id}/return |
sales:read |
List the customer returns already booked against this sales order, plus the quantity returned so far per sales-order line. Returns { returns, returnedByLine: { [sales_order_line_id]: quantity } }. |
POST /api/sales-orders/{id}/return |
sales:write |
Process a customer return against a fulfilled sales order. Per line, disposition restock (default), scrap or qc_hold decides what happens to the goods; restocks post Dr Inventory / Cr COGS at the original cost. Money moves only when refund: { method: cash|card|check|none, amount?, from_account_id? } is sent and the sale is paid (from_account_id is the bank or cash account it was paid from, else the cash role account); omitted on a paid sale, the return completes with refund.status pending and next_actions to POST /api/customer-returns/{id}/refund. Returns { return, invoices, next_actions? }; 409 account_roles_missing / qc_hold_location_missing / sale_not_paid, 400 refund_exceeds_refundable / store_credit_unsupported / invalid_from_account. |
/api/sales-quotes 11
| Operation | Scope | What it does |
|---|---|---|
GET /api/sales-quotes |
sales:read |
Estimates (sales quotes, Q-0001) for customers, newest first, each with effective_status (a sent quote past expiration_date reads expired) and expiringDigest (sent quotes expiring within 7 days). Filters: status (draft | sent | accepted | converted | invoiced | expired), customer_id, warehouse_id, location_id; anything else is a 400. A service estimate is billed with POST /api/sales-quotes/{id}/invoice. |
POST /api/sales-quotes |
sales:write |
Create a draft estimate for a customer. Lines are services or ad-hoc items (description, quantity, unit_price, optional account_id or account_code, which must be an income account, else 400 invalid_line_account; 400 unknown_account_code for a code not in the chart) or catalog products (product_id; description, price and account_id fill from the product, the customer's price list and the product's income_account_id when the line leaves them out; 400 product_not_found for an id not in this business). Lines keep the order sent. expiration_date must be on or after quote_date (400 invalid_date_range, field expiration_date). Then POST /send (marks it sent; nothing is emailed), /accept when the customer agrees, and /invoice to bill it. Any key not listed in the body is a 400 with a did-you-mean hint (expiry_date: did you mean expiration_date?). |
DELETE /api/sales-quotes/{id} |
sales:write |
Delete an estimate and its lines. A converted or invoiced estimate cannot be deleted. |
GET /api/sales-quotes/{id} |
sales:read |
One estimate: header, customer, lines in order (sort_order), effective_status, and what it became: invoice (id, invoice_number, status, total) when billed, or converted_sales_order for a goods order. |
PATCH /api/sales-quotes/{id} |
sales:write |
Edit a draft estimate's header (customer_id, warehouse_id, quote_number, quote_date, expiration_date, notes, metadata). Any other key is a 400; status changes are POST /send, /accept, /invoice or /convert. |
POST /api/sales-quotes/{id}/accept |
sales:write |
Record the customer accepting a sent estimate (sent -> accepted). A draft answers 400 quote_not_sent with next_actions POST /api/sales-quotes/{id}/send (marks it sent, emails nothing) then accept; other statuses 400 invalid_status_transition. An expired one must be re-sent with a new expiration_date first. Then bill it with POST /api/sales-quotes/{id}/invoice. |
POST /api/sales-quotes/{id}/convert |
sales:write |
Goods only: turn a sent/accepted quote of catalog products into a draft sales order (fulfill, then invoice). Needs a warehouse and a product on every line. For services or ad-hoc lines use POST /api/sales-quotes/{id}/invoice instead. |
POST /api/sales-quotes/{id}/invoice |
sales:write |
Bill a sent or accepted estimate as a draft invoice (services and ad-hoc lines; no warehouse needed). Lines copy in order with description, quantity, unit_price and income account (the line's account_id, else body account_id, else the revenue role account); a body account_id that is not an income account here is 400 invalid_account even when every line has its own. Same checks, terms, sales tax and numbering as POST /api/invoices; the invoice carries metadata.source_quote_id and the estimate becomes invoiced with invoice_id. 400 for a draft, expired or converted estimate, 409 quote_already_invoiced. Nothing is sent: POST /api/invoices/{id}/send issues it. |
GET /api/sales-quotes/{id}/pdf |
sales:read |
Generate and return a PDF for a quote (authenticated). Reuses the invoice-PDF pdfkit pattern via generateQuotePDF. |
POST /api/sales-quotes/{id}/send |
sales:write |
Mark a draft estimate sent (draft -> sent). It does not email or text anything (POST /api/invoices/{id}/send does): delivered is false and next_actions points at GET /api/sales-quotes/{id}/pdf to share it yourself. |
POST /api/sales-quotes/import |
sales:write |
Import the estimates still open at the switch date when migrating (switching) from QuickBooks, Xero or a spreadsheet: content (QuickBooks Estimates by Customer report CSV, or a Xero quotes export: ContactName, QuoteNumber, QuoteDate, ExpiryDate, Description, Quantity, UnitAmount, LineAmount, Total, Status; closed, declined, invoiced and deleted estimates and total lines are skipped) or rows [{ number, customer | customer_id, date, expiration_date, amount, accepted }]. Rows sharing a number, customer and date are lines of one estimate (one line per row, lines_count and the summed amount in the preview; total_note when the export's Total differs from its lines); otherwise each becomes an estimate with one line for its total (income_account_id sets the lines' income account), marked sent, or accepted when the export says so; nothing is emailed and nothing posts. Customers are matched by name (import them first with POST /api/customers/import); an estimate number the business already has is skipped. Send preview: true first: rows say would_create | exists | duplicate_in_file (a row repeating every column of an earlier one) | party_not_found | not_open | invalid. An unrecognised file answers 400 unreadable_file with headers_seen and column_mapping_example. Bill one later with POST /api/sales-quotes/{id}/invoice. |
/api/sales-reps 1
| Operation | Scope | What it does |
|---|---|---|
GET /api/sales-reps |
crm:read |
The sales reps a sales order can be attributed to (sales_orders.rep_user_id): id and display name of each salesman-role member. Needs can_post. |
/api/scheduling 30
| Operation | Scope | What it does |
|---|---|---|
GET /api/scheduling/availability |
scheduling:read |
List weekly availability rules. day_of_week is 0=Sunday through 6=Saturday; start_time/end_time are HH:MM wall-clock times in the scheduling settings timezone (GET /api/scheduling/settings). Filters: ?resource_id=<id> for one resource, ?resource_id=null for business-wide rules only, ?location_id= for the rules of that location's resources plus business-wide rules. |
POST /api/scheduling/availability |
scheduling:write |
Create a weekly availability rule: day_of_week (0=Sunday through 6=Saturday) with start_time/end_time as HH:MM in the scheduling settings timezone. resource_id null makes it business-wide; a resource with its own rules uses those instead of the business-wide ones. is_available false marks the window unavailable. |
DELETE /api/scheduling/availability/{id} |
scheduling:write |
Delete a weekly availability rule permanently. 404 when the id matches no rule. |
PATCH /api/scheduling/availability/{id} |
scheduling:write |
Update an availability rule (any of day_of_week 0=Sunday through 6=Saturday, start_time, end_time as HH:MM in the scheduling settings timezone, is_available, effective_from, effective_until). 404 when the id matches no rule. |
GET /api/scheduling/availability/overrides |
scheduling:read |
List date-specific availability overrides (holidays, special hours). Optional ?from= and ?to= (YYYY-MM-DD) bound override_date. |
POST /api/scheduling/availability/overrides |
scheduling:write |
Create a date-specific override for override_date (YYYY-MM-DD). is_available false (the default) closes that date, or only start_time to end_time when given; is_available true with start_time/end_time replaces the weekly rules for that date. Times are HH:MM in the scheduling settings timezone; resource_id null applies to every resource. |
GET /api/scheduling/blocks |
scheduling:read |
List blocks (time a resource cannot be booked: breaks, holidays, maintenance). Filters: ?resource_id=<id> or ?resource_id=null (business-wide), ?category=, ?recurrence= (once | daily | weekly), ?active_only=true. |
POST /api/scheduling/blocks |
scheduling:write |
Create a block: once (start_date to end_date), daily, or weekly (day_of_week 0=Sunday through 6=Saturday). start_time/end_time are HH:MM in the scheduling settings timezone; omit them for an all-day block. resource_id null blocks every resource. Bookings that overlap an active block are refused. |
DELETE /api/scheduling/blocks/{id} |
scheduling:write |
Delete a block permanently. PATCH { is_active: false } pauses it instead. |
GET /api/scheduling/blocks/{id} |
scheduling:read |
Get one block with its resource (id, name). |
PATCH /api/scheduling/blocks/{id} |
scheduling:write |
Update a block: title, category, recurrence, dates, day_of_week (0=Sunday), start_time/end_time (HH:MM in the scheduling settings timezone), effective range, or is_active false to pause it without deleting. |
GET /api/scheduling/bookings |
scheduling:read |
List bookings, oldest first. Filters: ?status=, ?from= and ?to= (ISO datetimes, compared against start_time), ?resource_id=, ?service_id=, ?location_id=. |
POST /api/scheduling/bookings |
scheduling:write |
Create a booking for a service (and optionally a resource). end_time must cover the service duration. Returns 409 { code: booking_conflict, conflicts } when the resource is already at capacity, or 409 { code: booking_rules_violated, violations, timezone } when the time is outside availability hours or overrides, overlaps a block, is inside min_booking_notice_minutes or past max_booking_advance_days, or sits inside the service buffer. Resend with override_rules: true to book outside the rules on purpose (recorded in metadata.rules_overridden). All rules use the scheduling settings timezone. |
DELETE /api/scheduling/bookings/{id} |
scheduling:write |
Delete a booking permanently. PATCH { status: "cancelled" } keeps the record instead. |
GET /api/scheduling/bookings/{id} |
scheduling:read |
Get one booking. |
PATCH /api/scheduling/bookings/{id} |
scheduling:write |
Update booking fields and/or move its status (validated transitions; cancelling stamps cancelled_at). Note: rescheduling here does not re-run the availability or conflict checks that POST /api/scheduling/bookings applies. |
GET /api/scheduling/members |
scheduling:read |
List the business's active members who can be assigned to a scheduling resource (user_id, display email, role, permissions), for linking a login to a resource. Scoped to scheduling so no CRM access is needed. |
GET /api/scheduling/resources |
scheduling:read |
List bookable resources (staff, rooms, equipment). Filters: ?active_only=true, ?location_id=. |
POST /api/scheduling/resources |
scheduling:write |
Create a bookable resource (a staff member, room, or piece of equipment) that bookings, availability rules and blocks can target. |
DELETE /api/scheduling/resources/{id} |
scheduling:write |
Delete a resource permanently. PATCH { is_active: false } retires it instead. |
GET /api/scheduling/resources/{id} |
scheduling:read |
Get one resource with its linked service ids. |
PATCH /api/scheduling/resources/{id} |
scheduling:write |
Update a resource. Only the fields sent are written. service_ids replaces the linked services; changing user_id (the login the resource belongs to) needs team-management permission. |
GET /api/scheduling/services |
scheduling:read |
List bookable services (duration_minutes, buffer_minutes, max_concurrent, price). Optional ?active_only=true. |
POST /api/scheduling/services |
scheduling:write |
Create a bookable service. duration_minutes is the minimum booking length, buffer_minutes is the gap kept around its bookings, max_concurrent is how many bookings one resource can take at once. |
DELETE /api/scheduling/services/{id} |
scheduling:write |
Delete a service permanently. PATCH { is_active: false } retires it instead. |
GET /api/scheduling/services/{id} |
scheduling:read |
Get one service. |
PATCH /api/scheduling/services/{id} |
scheduling:write |
Update a service (name, duration_minutes, buffer_minutes, max_concurrent, price, is_active, metadata). |
GET /api/scheduling/settings |
scheduling:read |
Get the business's scheduling settings: timezone (the zone every availability, block and override time is read in), slot_increment_minutes, min_booking_notice_minutes, max_booking_advance_days, allow_concurrent_bookings. null when never configured. |
PUT /api/scheduling/settings |
scheduling:write |
Create or replace the scheduling settings (upsert). timezone is an IANA zone; min_booking_notice_minutes and max_booking_advance_days bound which times POST /api/scheduling/bookings accepts. |
GET /api/scheduling/station-day |
scheduling:read |
Daily view for one resource: the client currently being served, the rest of the day's timeline, the attention queue, and the day's running totals. The day is the business-local date in the scheduling settings timezone (timezone in the response; generatedAt is the UTC instant date, isToday and nowTime were computed from). ?date=YYYY-MM-DD shows another day; defaults to the resource with appointments today, ?resource_id= pins one. station.resourceName is the resource's name (barberName is the same value for the barbershop screen). |
/api/screens 12
| Operation | Scope | What it does |
|---|---|---|
GET /api/screens |
screens:read |
This business's custom screens (its own pages in the app at /s/<slug>, built as one HTML document each), with each one's latest version and review status. To build one: PUT /api/screens/<slug> to register it, then PUT /api/screens/<slug>/files with the HTML; Padrino reviews it before anyone sees it. Returns { screens }. |
DELETE /api/screens/{slug} |
screens:write |
Remove a custom screen and all its versions. Returns { ok }. |
GET /api/screens/{slug} |
screens:read |
One custom screen and its versions (newest first, without the HTML). Returns { screen, versions }. |
PUT /api/screens/{slug} |
screens:write |
Register or update a custom screen's details (title, description, sidebar icon and order). The screen appears in the sidebar at /s/<slug> once Padrino approves a version of its HTML. Returns { screen, next_actions }. |
PUT /api/screens/{slug}/files |
screens:write |
Upload a custom screen's HTML as a new draft version: { html, note }. One self-contained document (inline CSS and JS, at most 256KB). Inside it, `await padrino.api(method, path, body)` calls the Padrino API as the person viewing the screen (their permissions; resolves to the JSON body, rejects with err.status and err.data); `padrino.navigate('/crm')` opens an app page; `padrino.onReady(theme => ...)` runs once the app sends its theme, also set as CSS variables --padrino-background, --padrino-foreground, --padrino-card, --padrino-card-foreground, --padrino-primary, --padrino-primary-foreground, --padrino-muted, --padrino-muted-foreground, --padrino-border and a dark class on <html>. The screen cannot fetch, load external files or open other sites, and keys, secrets, auth, business switching and screen or automation code are out of its reach. Nobody sees the version until Padrino approves it. Returns 201 { version, status, next_actions }. |
GET /api/screens/{slug}/frame |
screens:read |
The approved HTML of a custom screen, as served inside the app's sandboxed frame (signed-in users only; an agent reads versions through /api/screens/<slug>/versions/<n>). |
GET /api/screens/{slug}/versions/{version} |
screens:read |
One version of a custom screen, including its HTML. Returns { version }; an unknown version is 404, a non-numeric one 400. |
GET /api/screens/reviews |
screens:write |
Platform only (people in the platform owner's business): every custom screen version waiting for review, across all businesses, oldest first. Returns { reviews }. |
GET /api/screens/reviews/{id} |
screens:write |
Platform only: one custom screen version with its HTML, for review. Returns { version }. |
POST /api/screens/reviews/{id}/approve |
screens:write |
Platform only: approve a custom screen version so it is what its business sees at /s/<slug> (and in its sidebar). Supersedes the previously approved version and older drafts. Returns { version }. |
GET /api/screens/reviews/{id}/frame |
screens:write |
Platform only: a custom screen version's HTML as served in the sandboxed frame, for the reviewer's read-only preview. |
POST /api/screens/reviews/{id}/reject |
screens:write |
Platform only: reject a custom screen draft so it can never be approved. Returns { ok }. |
/api/settings 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/settings/preferences |
settings:read |
The business's effective accounting preferences (accounting_method, sales_tax_handling, draft_workflow, ...): business override, else platform default. Returns { preferences }. |
PATCH /api/settings/preferences |
settings:write |
Save any subset of the business's accounting preferences (null clears a key back to the platform default); the supported way to change them. Ask the owner before changing accounting_method or sales tax handling. draft_workflow and bookkeeper_access are owner-only: an API key gets 403 owner_only_setting. Returns the effective { preferences }. |
/api/setup 1
| Operation | Scope | What it does |
|---|---|---|
GET /api/setup/status |
ledger:read |
Start here for bookkeeping: how far these books are set up and the owner's numbers, in one call. Pass ?use= with what the owner picked (invoicing, expenses, money_in_out, loans, comma-separated; default full) to get only the steps that use needs; use_note says how it was scoped. snapshot: posted cash by account; receivables (open invoices net of unapplied credit, = GET /api/reports/ar-aging totals.total) with receivables_detail (other receivables, prepayments, opening_ar_check, opening_ar_future_dated, and credits_note: which credit figure to quote); payables (open bills net of vendor credit, = GET /api/reports/ap-aging totals.total) with payables_detail (sales tax and other payables no bill stands for, ap_account_check, opening_ap_check, grni_check, and a note to quote); credit_cards and loans owed; this_month income, expenses and net on ?basis=cash|accrual (default the accounting_method; quote its label and basis note); drafts and captures awaiting review. snapshot.opening_check lists bank and card accounts whose opening balance is missing, misdated, off the first reconciliation or of the wrong sign, each with ask_owner and the opening-entry draft (the first three in full; with more, opening_check_more lists the rest by account, code and reasons with opening_check_note, and ?verbose=true returns all in full); the cash and credit_cards blocks then carry total_unflagged and a note. owner: employee_slug and name for handing the owner a task, with the ask_owner recipe. checklist: each step with passed, detail, ask_owner (quotable to the owner), agent_note (for you) and actions (the calls that complete it). Full-bookkeeping steps: business_profile, prior_system (after a migration), equity_accounts, chart_of_accounts, account_roles (also pending while several revenue accounts exist and none holds the revenue role), opening_balance, first_transactions, drafts_reviewed, open_invoices_and_bills, sales_tax (also pending while another sales tax liability holds a balance), first_reconciliation, first_period_closed. use=invoicing adds products, customers and first_invoice (its next_actions run business_profile, products, customers; the retainer call POST /api/customers/{id}/credits is offered at first_invoice once a customer exists), and its sales_tax step re-opens with ask_owner when a taxable catalog item was added after the owner saved sales_tax_handling ignore; use=loans is business_profile and loans (no revenue role), and its snapshot is loans plus cash narrowed to the bank or cash accounts loan lines moved money through, with no this_month or basis; use=money_in_out is business_profile, chart_of_accounts, statement_imported (which bank and card accounts have statement rows and which have none), statement_rows_sorted (rows not yet sorted or waiting on the owner) and drafts_reviewed, and next_actions ends with GET /api/reports/cash-summary (money in and out by kind). With a light use the snapshot holds only that use's blocks (cash, this_month, awaiting_review, plus receivables for invoicing, cards for money_in_out and expenses, payables for expenses, loans for loans) and no opening_check: starting balances are not part of a light use, so cash and credit_cards carry no flags and no 'may be off' note, and when a bank account has no starting balance cash.note says to quote it as money recorded there (for invoicing, payments collected), not the bank balance; a light use also lists only the 5 largest cash accounts and the 5 largest credit_cards (accounts_more counts and sums the rest) and the 5 largest customers in receivables_detail.prepayments and customer_credits (customers_more), and its basis.adjustments.items leaves out bills unless expenses is picked (items_note); scope_note says what was left out and ?verbose=true returns it whole. next_actions: the opening checks (full bookkeeping only), then the actions of the first three pending steps, in order. ?brief=true is a small first read (a few KB on large books): snapshot totals with accounts_count instead of account lists, opening_check_count, checklist as key, label and passed, pending_count, the first 3 next_actions and next_actions_count; call again without brief for the rest. |
/api/shifts 22
| Operation | Scope | What it does |
|---|---|---|
GET /api/shifts |
shifts:read |
List shifts, ordered by starts_at. Filters: ?from= and ?to= (ISO datetimes with offset, compared against starts_at), ?employee_profile_id=, ?department_id=, ?status= (scheduled | published | completed | cancelled). |
POST /api/shifts |
shifts:write |
Create a shift (status scheduled unless given; leave employee_profile_id null for an open shift). Requires can_post. Returns 409 { code: shift_conflict, overlapping_shifts, time_off } when the employee already has an overlapping non-cancelled shift or approved time-off; resend with override_conflicts: true to save anyway. |
DELETE /api/shifts/{id} |
shifts:write |
Delete a shift permanently. Requires can_post. To keep history, PATCH { status: "cancelled" } instead. |
PATCH /api/shifts/{id} |
shifts:write |
Update one shift: reassign (employee_profile_id, null = open shift), reschedule, relabel, or change status (e.g. { status: "published" } stamps published_at; completing a covered shift credits the fairness ledger). Requires can_post. A change to employee or times returns 409 { code: shift_conflict, overlapping_shifts, time_off } on a double booking or approved time-off; resend with override_conflicts: true to save anyway. |
GET /api/shifts/clock |
shifts:read |
The calling user's punch state: their employee profile and their open time entry, if clocked in. |
POST /api/shifts/clock-in |
shifts:write |
Clock the calling user in (their own employee profile, resolved server-side). Optional shift_id links the punch to a shift. Returns 404 when the caller has no employee profile and 409 when already clocked in. Punches made with an API key are recorded with source 'api'. |
POST /api/shifts/clock-out |
shifts:write |
Clock the calling user out, closing their open time entry (optional note). Returns 404 when they have no profile or are not clocked in. |
GET /api/shifts/config |
shifts:read |
Get the business's coverage-fairness params used to rank fill-in candidates, falling back to defaults when unset. |
PUT /api/shifts/config |
shifts:write |
Replace the business's coverage-fairness params (an empty body resets to defaults). Requires can_post. |
GET /api/shifts/departments |
shifts:read |
List work departments (name, min_coverage). |
POST /api/shifts/departments |
shifts:write |
Create a work department. min_coverage is the staffing floor time-off evaluation checks against. Requires can_post. |
PATCH /api/shifts/departments/{id} |
shifts:write |
Rename a department or change its min_coverage (the floor time-off evaluation checks against). Requires can_post. |
GET /api/shifts/employees |
shifts:read |
List the business's employee scheduling profiles (department, employment terms, is_schedulable, hourly_rate). The id is the employee_profile_id used by shifts and time-off. |
POST /api/shifts/employees |
shifts:write |
Create an employee scheduling profile for a business member (user_id from GET /api/scheduling/members). Requires can_post. Returns 400 not_schedulable_member when user_id is not an active member or is the API service account (role 'service'). |
PATCH /api/shifts/employees/{id} |
shifts:write |
Update an employee scheduling profile: department, employment terms, hourly_rate, call-off channels, or is_schedulable (whether they appear in the schedule roster). Requires can_post. |
GET /api/shifts/me |
shifts:read |
The calling user's own upcoming published shifts (drafts stay manager-only until published). Optional ?from= (ISO datetime) replaces the default of now; anything else is 400. |
POST /api/shifts/publish |
shifts:write |
Publish every 'scheduled' shift whose starts_at falls in [from, to], optionally only one department_id: the "publish the week" action. Requires can_post. from/to take YYYY-MM-DD (whole days in the scheduling settings timezone, to inclusive) or ISO datetimes with offset. Returns the count, the published shifts, and the resolved range. |
GET /api/shifts/time-off |
shifts:read |
List time-off requests (manager view). Filters: ?status=, ?employee_profile_id=, ?from= and ?to= (ISO datetimes, compared against starts_at). |
POST /api/shifts/time-off |
shifts:write |
File a time-off request for any employee (manager action, requires can_post). It is evaluated on submit for shift conflicts, the department coverage floor and company events: flagged requests land in flagged_for_review, the rest in pending. |
PATCH /api/shifts/time-off/{id} |
shifts:write |
Decide a time-off request: approved, denied, or flagged_for_review. Requires can_post. On approval the response lists affected_shifts (the employee's scheduled/published shifts inside the window) with warnings and next_actions; send unassign_conflicting_shifts: true to unassign them in the same call (unassigned_shift_ids), leaving open shifts to fill. |
GET /api/shifts/time-off/me |
shifts:read |
The calling user's own time-off requests. |
POST /api/shifts/time-off/me |
shifts:write |
File a time-off request for the calling user (their profile is resolved server-side; no can_post needed). Evaluated on submit like the manager path. |
/api/site-analytics 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/site-analytics |
analytics:read |
Website traffic for the business's sites, counted from each site's own server log (no cookies): daily visitors, page views, applications, bot hits, and a summary over the range with top pages, sources (Google, Instagram, ChatGPT...), button clicks and AI crawler visits. ?site_host= picks a site (default: the most recent), ?days= the range (default 30). Returns { hosts, site_host, rows, summary }. |
POST /api/site-analytics/daily |
analytics:write |
Store one day of website totals for a site (visitors, page views, applications, bot hits and top-N breakdowns). Posting the same site and day again replaces it, so a nightly job can safely re-run. Returns { day }. |
/api/site-content 5
| Operation | Scope | What it does |
|---|---|---|
GET /api/site-content |
content:read |
List the business's website content items (site-content: pages, widgets, components) as { data }. Website CMS content, not part of the books. |
POST /api/site-content |
content:write |
Create a website page (slug, title, type, status, body, optional SEO fields). Publishing sets published_at. |
DELETE /api/site-content/{id} |
content:write |
Delete a website page. Default pages cannot be deleted (400). 400 for a malformed id, 404 when it is not in this business. |
GET /api/site-content/{id} |
content:read |
One website page (site-content item: slug, title, type, status, body, SEO fields). 400 for a malformed id, 404 when it is not in this business. |
PATCH /api/site-content/{id} |
content:write |
Update a website page. Publishing sets published_at; the slug of a default page cannot change. 400 for a malformed id, 404 when it is not in this business. |
/api/smart-lists 9
| Operation | Scope | What it does |
|---|---|---|
GET /api/smart-lists |
crm:read |
List the business's smart lists (custom data tables) with their field schemas and row counts. Returns { lists }. |
POST /api/smart-lists |
crm:write |
Create a smart list: a named custom table with a field schema. The name is a permanent slug. Returns 201 { list }; 409 if the name is taken. |
DELETE /api/smart-lists/{id} |
crm:write |
Delete a smart list and its rows. 404 for an unknown id. |
GET /api/smart-lists/{id} |
crm:read |
Get one smart list with its field schema. Returns { list }; 400 for a malformed id, 404 for an unknown one. |
PATCH /api/smart-lists/{id} |
crm:write |
Update a smart list's label, description or field schema. The name cannot change (delete and recreate instead). Returns { list }. |
GET /api/smart-lists/{id}/rows |
crm:read |
List the rows of a smart list. Optional ?where= JSON filter on the list's fields (equals, or lt/lte/gt/gte/ne/in/contains) and ?limit. Returns { rows }. |
POST /api/smart-lists/{id}/rows |
crm:write |
Add a row to a smart list. `data` maps field names to values and must match the list's field schema (400 otherwise). Returns 201 { row }. |
DELETE /api/smart-lists/{id}/rows/{rowId} |
crm:write |
Delete one row from a smart list. 404 when the row is not on this list. |
PATCH /api/smart-lists/{id}/rows/{rowId} |
crm:write |
Update a smart list row. Replaces the row's data, or with merge: true changes only the fields sent. Fields not on the list are refused; the result must match the list's field schema (400 otherwise). Returns { row }. |
/api/support 5
| Operation | Scope | What it does |
|---|---|---|
GET /api/support/tickets |
support:read |
Support tickets visible to the caller, newest first: a tenant sees its own. ?status, ?q (subject/body text), ?limit (default and max 200), ?brief=true cuts bodies to 280 characters so long lists fit; GET /api/support/tickets/{id} returns one ticket with its thread. |
POST /api/support/tickets |
support:write |
File a support ticket: subject (3-200 characters; put the detail in body), body (up to 20,000 characters), category and severity. Returns the ticket; GET /api/support/tickets/{id} reads it with its thread. |
GET /api/support/tickets/{id} |
support:read |
One support ticket with its comment thread. The id may be the full UUID or a unique prefix of at least 8 hex characters (9785a650); a malformed id is 400 invalid_ticket_id, an ambiguous prefix 409. |
PATCH /api/support/tickets/{id} |
support:write |
Set a support ticket's status (and, for the platform desk, its linked task). Returns the updated ticket without its body. The id may be a unique 8+ character prefix. |
POST /api/support/tickets/{id}/comments |
support:write |
reply on a ticket thread. Body: { body }. author_business_id records which side wrote it, so the thread renders reporter vs platform without leaking anything else. |
/api/tools 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/tools/qr-code |
tools:qr |
Render a QR code from query params (data required; ecc, margin, size, dark, light, moduleStyle optional). Returns image/svg+xml, or { svg, version, moduleCount } with ?format=json. |
POST /api/tools/qr-code |
tools:qr |
Render a QR code from a JSON body; unlike GET it accepts a center logo as a base64 data URI. Returns image/svg+xml, or { svg, version, moduleCount } with ?format=json. |
/api/v1 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/v1/quickcapture |
|
Returns available intents and API metadata. Requires a valid session or key. |
POST /api/v1/quickcapture |
quickcapture:write |
Natural-language capture behind the QuickCapture page and chat: plain text, structured fields (type, amount, vendor, ...) or an explicit intent, interpreted by Padrino's own AI. With dry_run: true, returns detected_intent, params, would_execute and the errors/warnings that block it, without executing. An agent that can read the chart of accounts should usually decide the entry itself and call POST /api/entries/draft (with a receipt if it has one) instead. |
/api/vendor-credits 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/vendor-credits |
ledger:read |
List vendor credits (money a vendor owes back: credit notes, refunds, overbilling corrections), newest first, with lines, applications to bills, refunds and remaining_credit. Filters: vendor_id, status (open = has unused credit, closed, void), bill_id (issued against or applied to that bill). Paged with limit (default 50, max 500) and offset; count is the total matching and has_more says another page exists. brief=true drops lines, applications and refunds. An unknown parameter is a 400. |
POST /api/vendor-credits |
ledger:write |
Record a vendor credit (a credit note from a contractor or supplier, a SaaS refund, an overbilling correction): { vendor_id, lines: [{ description, quantity?, unit_price, account_id? or account_code? }], credit_date?, reference?, source_bill_id?, apply_to_bill_id?, refund?: { deposit_account_id?, refund_date?, payment_method?, bank_transaction_id? }, reason? } (an unknown key inside refund is a 400). refund.bank_transaction_id names the bank-feed row (money in) the refund arrived as: the account and date come from it, its amount must equal what is left after apply_to_bill_id, and the row is linked to the refund entry (refund.bank_transaction). Numbered VC-####. Posts Dr Accounts Payable / Cr each line's expense account (line account_id, else the vendor's default_expense_account_id; 400 expense_account_required when neither) on credit_date. apply_to_bill_id lowers an open bill's balance_due with no cash (application.bill_balance_due and bill_status are the bill's after it, with a next_action to pay what is left; on an opening-balance bill it also posts Dr the bill's opening payable account / Cr A/P, application.posted_entry_id); refund records money the vendor paid back (Dr the deposit account / Cr A/P) and is the only part that lowers the vendor's 1099 total. Unapplied credit nets the vendor in AP aging. With credit left and no refund block, an open money-in row the bank feed reads as this credit's refund (at most what is left, the exact amount first; the vendor's name or VC number) is refund_rows_waiting and the refund next_action carries its { amount, payment_method, bank_transaction_id }. Never use a negative bill for this. Any key not listed in the body is a 400 with a did-you-mean hint (bill_id: did you mean apply_to_bill_id?). |
GET /api/vendor-credits/{id} |
ledger:read |
One vendor credit with its lines (and the account each credits), posted_entry_id, bill applications, refunds (each with posted_entry_id) and remaining_credit. |
POST /api/vendor-credits/{id}/applications/{appId}/void |
ledger:write |
Undo a vendor credit applied to the wrong bill: { reason, confirmClosedPeriod?, confirmReconciledPeriod? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. appId is from bill_credit_applications on GET /api/vendor-credits/{id}. Hands the amount back to the credit (amount_applied drops, status open, remaining) and to the bill (credit_applied drops, it reopens as open or partial with balance_due), and keeps the application with voided_at, void_reason and voided_by; A/P aging, cash-basis reports, job costing and bank-feed bill matches skip it. An ordinary application posted nothing, so nothing posts; one onto an opening-balance bill gets the mirror of its entry on its own date (void_entry_id). Then apply the credit to the right bill with POST /api/vendor-credits/{id}/apply. 404 vendor_credit_not_found, application_not_found, 409 already_voided, credit_void, bill_cancelled, amounts_changed, status_changed, reversal_draft_pending, closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true. |
POST /api/vendor-credits/{id}/apply |
ledger:write |
Apply a vendor credit to an approved bill (status open or partial) from the same vendor: { bill_id, amount?, applied_date? }. amount defaults to the smaller of the remaining credit and the bill's balance_due. Lowers balance_due and AP aging with no cash and posts nothing (the credit already debited A/P when it was recorded), except on an opening-balance bill (opening_balance_account_id set), where it posts Dr that opening payable account / Cr the A/P the credit debited and returns posted_entry_id (a closed applied_date month answers 409 closed_period unless confirmClosedPeriod); clearing the balance marks the bill paid. While a balance is left, next_actions pay it: the bill payment with bank_transaction_id for each bank row waiting on the feed that pays exactly that balance and names the bill or its vendor (bank_rows_waiting), else POST /api/bills/{id}/payments { amount: balance_due }. Not a payment: it never appears in bill payments or the 1099 total. 409 codes: credit_not_open, vendor_mismatch, bill_not_open (draft: edit it; paid: record a refund instead), nothing_due, exceeds_remaining, exceeds_balance, applied_before_credit (applied_date before the credit_date; checked before closed_period). |
POST /api/vendor-credits/{id}/refund |
ledger:write |
Record a vendor paying back some or all of a vendor credit in cash: { amount?, deposit_account_id?, refund_date?, payment_method?, reference?, bank_transaction_id?, confirmClosedPeriod?, confirmReconciledPeriod? }; an unknown key is a 400. amount defaults to the remaining credit (to the bank row's amount with bank_transaction_id). bank_transaction_id names the bank-feed row (money in) the refund is (POST /api/bank-feed/process outcome vendor_credit_refund, or vendor_credits[] on GET /api/bank-feed/transactions/{id}/matches): deposit_account_id and refund_date default to the row's account and date (400 deposit_account_mismatch for another account), amount must equal the row (400 bank_amount_mismatch), 409 transaction_already_matched when the row is already in the books (a row still linked to the entry of a voided payment or refund, with no bank row showing that money going back, is taken over instead), and the row is linked to the refund entry in the same call (bank_transaction { id, status matched, matched_entry_id }); never also create-draft that row, the credit already took the expense back. Posts Dr deposit_account_id (bank, cash or the credit card refunded; default the cash role) / Cr Accounts Payable on refund_date (default the business's today in its time zone, never future except the bank_transaction_id row's date, which the import allows a day ahead, never before the credit's credit_date: 409 refund_before_credit, void and reissue the credit with the right date; a closed month answers 409 closed_period, and 409 reconciled_period when the date is on or before the statement date of that account's last completed bank reconciliation (resend with confirmReconciledPeriod: true once the owner agrees, or reopen it)). 409 exceeds_remaining (with remaining_credit) when amount is more than the credit has left. Refunds from 1099 vendors lower that vendor's total in GET /api/reports/1099-summary for the refund's year. |
POST /api/vendor-credits/{id}/refunds/{refundId}/void |
ledger:write |
Undo a vendor refund recorded in error or on the wrong date: { reason, confirmClosedPeriod?, confirmReconciledPeriod? }; reason is required (ask the owner why; it is stored as void_reason and in the audit trail), and a missing or blank reason or an unknown key is a 400. refundId is from vendor_credit_refunds on GET /api/vendor-credits/{id}. Posts the mirror of the refund's entry on the refund's own date (void_entry_id), lowers the credit's amount_refunded (it reopens with remaining), sends the bank-feed row matched to the refund back to unmatched (bank_rows_released), and keeps the refund with voided_at, void_reason and void_entry_id; A/P aging and the 1099 summary skip it. Then re-record it with POST /api/vendor-credits/{id}/refund, or void the credit with POST /api/vendor-credits/{id}/void once nothing else used it. 404 refund_not_found, 409 already_voided, status_changed, reversal_draft_pending, closed_period until resent with confirmClosedPeriod: true, reconciled_period until resent with confirmReconciledPeriod: true. |
POST /api/vendor-credits/{id}/void |
ledger:write |
Void a vendor credit entered in error, while none of it has been applied or refunded: { reason?, confirmClosedPeriod? }. Posts the reverse of its Dr A/P / Cr expense entry on the credit's date (void_entry_id) and keeps the record with status 'void' and metadata.void_reason. 409 credit_used once any of it was applied or refunded (next_actions undo each application with POST /api/vendor-credits/{id}/applications/{appId}/void and each refund with .../refunds/{refundId}/void), 409 bill_overpayment_credit for the extra of a bill payment (next_actions: undo its applications and refunds, then void the payment with POST /api/bills/{billId}/payments/{paymentId}/void; apply and refund while some is left), 409 vendor_return_credit, 409 already_void, 409 closed_period when the credit's month is closed. |
/api/vendor-pricing 10
| Operation | Scope | What it does |
|---|---|---|
GET /api/vendor-pricing/cost-lists |
purchasing:read |
List the business's vendor cost lists (the buy-side price tiers vendors are assigned). Optional ?active=true|false (anything else is a 400). |
POST /api/vendor-pricing/cost-lists |
purchasing:write |
Create a named vendor cost list. Add its quantity-break tiers with POST /api/vendor-pricing/cost-lists/{id}/items. |
DELETE /api/vendor-pricing/cost-lists/{id} |
purchasing:write |
Delete a vendor cost list permanently, with its tiers. Vendors assigned to it are unassigned and fall back to each product's base cost. PATCH { is_active: false } keeps it instead. |
GET /api/vendor-pricing/cost-lists/{id} |
purchasing:read |
Get one vendor cost list with its quantity-break tiers. |
PATCH /api/vendor-pricing/cost-lists/{id} |
purchasing:write |
Rename a vendor cost list, turn it on or off, or update metadata. is_active: false keeps the list and the vendors assigned to it. |
GET /api/vendor-pricing/cost-lists/{id}/items |
purchasing:read |
List the quantity-break tiers on a vendor cost list, by product then minimum quantity. |
POST /api/vendor-pricing/cost-lists/{id}/items |
purchasing:write |
Add a quantity-break tier to a vendor cost list: product_id, min_quantity, unit_cost and an optional effective / expiration date window. A tier with the same product, min_quantity and effective_date already on the list is a 409. |
DELETE /api/vendor-pricing/cost-lists/{id}/items/{itemId} |
purchasing:write |
Remove one quantity-break tier from a vendor cost list. |
PATCH /api/vendor-pricing/cost-lists/{id}/items/{itemId} |
purchasing:write |
Update one tier on a vendor cost list: product, min_quantity, unit_cost or its date window. |
GET /api/vendor-pricing/resolve |
purchasing:read |
The buy cost for a purchase order line, as POST /api/purchase-orders would apply it. ?product_id (required, UUID), ?vendor_id (UUID), ?quantity (base unit, default 1), ?entered_cost (checks a typed cost for anomalies), ?as_of (YYYY-MM-DD, default today). Returns resolvedCost (the vendor's cost-list tier for that quantity, else the product's base cost, else 0), anchorListCost (the cost-list tier only, or null), recentReceiptAvg (average of recent receipts from this vendor for the product), fallbackBaseCost, costListId, matchedTier and anomaly (when entered_cost is far from those anchors, else null). |
/api/vendor-returns 2
| Operation | Scope | What it does |
|---|---|---|
GET /api/vendor-returns |
purchasing:read |
List vendor returns (RMAs) for the business, newest first. Optional filters: ?status=open|completed|cancelled, ?purchase_order_id=, ?warehouse_id=. Creation lives on POST /api/purchase-orders/:id/return (tied to the PO). |
GET /api/vendor-returns/{id} |
purchasing:read |
A vendor return with its lines, the destock inventory movements it booked (linked by reference_id), and the AP debit-memo ledger entry if one posted. An unbilled PO return is destock-only, so journalEntry is null in that case. |
/api/vendors 8
| Operation | Scope | What it does |
|---|---|---|
GET /api/vendors |
ledger:read |
Vendors (suppliers and contractors) of the business by name, active only unless include_inactive=1. ?search= narrows by part of the name, email or tax id: check it before POST /api/vendors so one contractor isn't split across two records (that splits their 1099 total). Each vendor has is_1099, tax_id and default_expense_account_id. Unknown parameters are a 400. |
POST /api/vendors |
ledger:write |
Create a vendor. Set default_expense_account_id (or default_expense_account_code, its chart code) so bill lines and create-draft { vendor_id } without an account debit it at approve, and is_1099 for contractors. A 1099 vendor created without a usable tax_id (blank, or a placeholder like 00-0000000) comes back with warnings [{ code: missing_tax_id, ask_owner, next_actions }]: ask the owner for the W-9 before paying them. A 1099 tax_id that is not 9 digits (EIN NN-NNNNNNN or SSN NNN-NN-NNNN) is saved but answers code invalid_tax_id: ask the owner to check the W-9. Refuses with 409 code possible_duplicate (listing the existing matches and changing nothing) when a vendor with the same name or tax_id exists; reuse that id, or resend with confirm_duplicate: true if it really is a different vendor. Unknown keys are a 400 naming the closest field. |
DELETE /api/vendors/{id} |
ledger:write |
Archive a vendor if it has bills, otherwise hard-delete. |
GET /api/vendors/{id} |
ledger:read |
Fetch a single vendor. |
PATCH /api/vendors/{id} |
ledger:write |
Update a vendor (is_active: false archives it). default_expense_account_code sets default_expense_account_id by chart code; unknown keys are a 400 naming the closest field. When the change sets is_1099 or tax_id and leaves a 1099 vendor without a usable tax id (blank, or a placeholder like 00-0000000), the answer adds warnings [{ code: missing_tax_id, ask_owner, next_actions }]: ask the owner for the W-9. A 1099 vendor's tax id that is not 9 digits (EIN NN-NNNNNNN or SSN NNN-NN-NNNN; 12 or a last-4), or one the IRS never issues (an SSN with area 000 or 666, group 00 or serial 0000, an EIN prefix like 00 or 07), is saved but answers code invalid_tax_id: ask the owner to check the W-9. When the change replaces a usable tax id with an unusable one, the warning says so (replaced_tax_id { last4 }) and its first next_action puts the old one back if it was a mistake. A W-9 task tied to the vendor that is already complete is named as completed_task with a reopen next_action (PATCH /api/pm/tasks/{id} { status: "to do" }) instead of a new task. A tax id another vendor of the business already has (the check POST answers 409 possible_duplicate for) is saved too, with warnings [{ code: shared_tax_id, tax_id, vendors [{ vendor_id, vendor_name }] (this vendor first), message, ask_owner, next_actions }] first: ask the owner whether it is the same contractor (bill the existing vendor and archive this one) or a mistyped W-9 (PATCH the right { tax_id }); left alone, the 1099 summary flags the pair at filing time. A usable tax id saved on a 1099 vendor whose W-9 task is still open (tied to the vendor, or one asking for its W-9) adds w9_task { code: w9_task_can_close, task, message, next_actions } and next_actions to comment on and close that task (PATCH /api/pm/tasks/{id} { status: complete }). |
GET /api/vendors/{id}/prior-1099-payments |
ledger:read |
Old-system 1099 payments recorded for a vendor (?year=YYYY; include_replaced=true shows restated figures too). These are what was paid before a mid-year switch; GET /api/reports/1099-summary adds them as priorSystemPaid. |
POST /api/vendors/{id}/prior-1099-payments |
ledger:write |
Record what a 1099 vendor was paid in the old system during a year before the books here started (mid-year switch), from the old system's 1099 or contractor payments report: { year, amount, as_of, source }. as_of is the day before the books start; one on or after it is refused with 409 prior_payments_overlap_books (the in-books payments it would count twice, ask_owner) unless confirm_overlap: true (stored as overlap_confirmed_total; the 1099 summary then lists it under coverage.priorOverlapsConfirmed, not priorOverlapsBooks, until more is paid here in that window). Leave out card payments (1099-K). GET /api/reports/1099-summary adds it to the vendor's total as priorSystemPaid. Posting again for the same year replaces the figure (replaced shows the old one, which is kept). Nothing is posted to the ledger. A 1099 vendor with no usable tax id gets w9_warning (missing_tax_id or invalid_tax_id) with ask_owner and its next_actions; once the year's total, this amount included, is at or over the 1099 threshold it carries backup_withholding and ask_owner leads with the 24% backup withholding. |
POST /api/vendors/import |
ledger:write |
Import a vendor list when migrating (switching) from QuickBooks, Xero or a spreadsheet: content (the exported CSV; title lines above the header are fine, columns are auto-detected and column_mapping overrides them; terms come from a Terms text column (QuickBooks: Net 15) or Xero DueDateBillDay / DueDateBillTerm into default_terms_days, a month-based Xero term is left out with a row warning; a phone that is not a phone number is left out with a row warning, like POST /api/vendors) or rows [{ name, email, phone, address, terms, tax_id, is_1099, account_number }]. Send preview: true first: each row comes back would_create, exists (same name as a vendor of the business, inactive ones included: existing_id, matched_on; never overwritten), possible_match (same email or tax id but a different name, often a mistyped tax id: existing_name, matched_on; ask the owner via ask_owner and resend with create_anyway_rows [row numbers] for the different vendors), duplicate_in_file or invalid. The commit creates only the would_create rows and returns created [{ row, id, name }] and not_imported, so importing the same file again is safe. Rows with is_1099 and no usable tax_id (blank or a placeholder like 00-0000000) add warnings [{ code: missing_tax_id, rows [{ row, name, vendor_id }], ask_owner }] on preview and commit: ask the owner for those W-9s before paying them. create_anyway_warnings lists row numbers that are not possible_match rows (ignored). A create_anyway row that matched on tax id is created with shares_tax_id_with, and warnings[] carries shared_tax_id { rows, ask_owner, next_actions PATCH /api/vendors/{id} { tax_id } } because two vendors on one tax id means one is likely mistyped (the 1099 summary flags it too). Next: POST /api/bills/import-open for the unpaid bills. |
/api/warehouse 4
| Operation | Scope | What it does |
|---|---|---|
GET /api/warehouse/locations |
warehouse:read |
Bins, zones and aisles inside the business's warehouses (where stock sits; no ledger effect). Optional ?warehouse_id and ?parent_id (UUIDs; parent_id=null for top-level only) and ?active=true|false; any other value is a 400. |
POST /api/warehouse/locations |
warehouse:write |
Create a bin/zone/aisle. Code is normalized (trimmed, upper-cased) and unique per (business, warehouse). A parent, if given, must live in the same warehouse. |
GET /api/warehouse/locations/{id} |
warehouse:read |
One warehouse bin, zone or aisle by id (code, kind, parent, pickable and active flags). 400 for a malformed id, 404 for one not in this business. |
PATCH /api/warehouse/locations/{id} |
warehouse:write |
Update a bin/zone/aisle. Code is re-normalized; a new parent must live in the same warehouse and may not create a cycle. |
Give your AI a business to run.
Start with one connected system instead of assembling a stack of subscriptions first. Free. No credit card needed.