Advisory Firms
An advisory firm is an accounting or bookkeeping practice that manages many client businesses. The firm routes sit under /api/v1/advisory-firms and work on the firm as a whole: who is on staff, which clients they can reach, shared templates, and reports that span clients. Everything inside one client's books (invoices, bills, transactions) still uses that client's own routes with x-business-id.
Firm routes are firm-scoped: they take Authorization: Bearer and the firm in the path, and they do not need x-business-id. Responses use the usual {"success": true, "data": ...} envelope; examples below show data. List endpoints that paginate use the standard cursor parameters. Errors follow Error Handling.
# Find your firm_id (the firms the token's user belongs to)
curl "https://api.ondayzero.com/api/v1/advisory-firms" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Accept: application/json"Each firm has id, name, address, ein, logo_url, client_messaging_enabled, ap_automation_enabled, client_visible_integrations, client_ai_assistant_enabled, client_digest_email_enabled, client_digest_frequency (weekly or monthly), mfa_enforced, slack_connected, slack_webhook_url_masked and, for owners and admins only, stripe_customer_id. client_ai_assistant_enabled controls whether clients get the read-only Regent chat in the client portal. The routes below use this FIRM_ID.
Roles and the permission model
Every firm member holds one role. The role decides both what they can do to the firm (the routes in this guide) and what they can do inside client businesses they reach through the firm.
| Role | Reaches | Permissions on those client businesses |
|---|---|---|
owner |
Every active client of the firm | read, write, delete, admin, manage_users, billing |
admin |
Every active client of the firm | read, write, delete, admin, manage_users (no billing) |
member |
Only clients assigned directly or through a team | read, write, delete, admin |
client |
Only businesses explicitly granted to them | read |
member is firm staff: on the clients it reaches it has admin but not manage_users or billing. client is a read-only portal user and counts as a firm member for the checks below, but never as staff.
Firm routes apply one of these gates:
| Gate | Who passes | Routes |
|---|---|---|
| Any firm member | owner, admin, member, client | Reads of the firm, its logo, businesses, users, clients, entity groups, team list, reports, consolidated reports, cleanup reads, AI insights, Slack status |
| Firm staff | owner, admin, member | Reading templates and their deployments; cleanup writes, the resolution history and the workpaper PDF |
| Firm admin | owner, admin | Every other write: firm settings, staff invites, teams, assignments, client access, linking businesses, templates, entity groups |
| Firm owner | owner | Granting the owner role, changing or removing an owner |
| DayZero operator | superuser | POST /advisory-firms (create a firm) |
A caller who is not in the firm gets 403 AUTH_002; a member of an archived firm gets 403 AUTH_074. Superusers (DayZero staff) pass every gate.
Scoped reads. Where a route is open to any member, member and client callers see only the businesses already in their access, and owners and admins see the whole portfolio. Firm lists, reports, entity groups, consolidated reports and cleanup data are all filtered this way. A scoped caller asking for a group or business outside their access gets a 404 or an empty result, never a hint that it exists.
API access. A staff member also needs API access approved by DayZero, and the firm can switch it off per person (see API access). See Authentication for tokens, the read_only scope (blocks every write below) and rate limits.
The firm
| Method | Path | Gate | Notes |
|---|---|---|---|
GET |
/advisory-firms |
member | The firms you belong to; user_id lists another user's firms (superuser only) |
GET |
/advisory-firms/{firm_id} |
member | Non-admins get stripe_customer_id: null |
PUT |
/advisory-firms/{firm_id} |
admin | Partial update of any field in the list above |
GET |
/advisory-firms/{firm_id}/logo |
member | {"download_url": "...", "firm_id": "..."}, a short-lived presigned URL |
PUT |
/advisory-firms/{firm_id}/stripe-customer |
admin | {"stripe_customer_id": "cus_..."}; only stores the reference |
DELETE |
/advisory-firms/{firm_id} |
admin | Browser sessions only, see below |
POST |
/advisory-firms |
operator | Firms come from sign-up links and checkout; others get 403 AUTH_071 |
curl -X PUT "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"client_messaging_enabled": true, "client_visible_integrations": ["plaid", "stripe"]}'client_visible_integrations accepts stripe (shown to clients as Invoicing), plaid (Financial Institutions), ramp, square, shopify, slack (anything else is a 400). The list you send replaces the previous one, so include every integration clients should see. There is no switch for it in the app yet. PUT validates slack_webhook_url but does not store it; connect Slack with the dedicated routes below.
DELETE /advisory-firms/{firm_id} removes every user association and unassigns every business, with no undo. It needs a fresh identity check that a long-lived API token cannot give, so with a token it returns 403 AUTH_057. Do it in the web app. (The body is still required, send {}; the old confirm_password field is ignored.)
Staff: members, roles, invitations
List and manage members
curl "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/users" \
-H "Authorization: Bearer dz_your_token_here"Returns {"users": [...], "total": n}. Each user has id, user_id, email, first_name, last_name, photo_url, onboarded, role, api_access_enabled, visible_sections, created_at, updated_at. Callers who are not owner or admin see email as "[redacted]" and no names. DayZero support accounts on the roster are hidden from firm staff.
| Method | Path | Gate | Body |
|---|---|---|---|
POST |
/advisory-firms/{firm_id}/users |
admin | {"user_id" or "email", "role"}, role defaults to member |
PUT |
/advisory-firms/{firm_id}/users/{user_id}/role |
admin (manage_users) |
{"role": "owner" | "admin" | "member" | "client"} |
DELETE |
/advisory-firms/{firm_id}/users/{user_id} |
admin (manage_users) |
none |
POST /users adds an existing user (unknown email is 404; already in the firm is 400 VALIDATION_057). To bring in someone new, invite them. Adding or demoting someone to member with no assignments or team gives them every active client so they are not locked out; narrow it afterwards with assignments.
Owner protection: only an owner can grant owner, change an owner's role, or remove an owner (403 AUTH_048 otherwise), and the last owner can never be removed or demoted (400 VALIDATION_464); transfer ownership first.
Invite staff
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/users/invite" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"email": "bookkeeper@example.com",
"role": "member",
"first_name": "Sam",
"message": "Welcome aboard",
"business_ids": ["019ab37c-b051-7000-8000-000000000001"]
}'role is admin or member (default member); owners cannot be invited. message is up to 500 characters. For a member you must give exactly one access choice: business_ids, team_id (a team to join on acceptance), or assign_all_firm_businesses: true. Combining them, or giving none, is a 400. Admins ignore all three. The response is {"status", "reason", "message", "email_sent"}: status is added (the person already had a verified account and joined immediately), invited (an email was sent and they join on signup) or resent (a pending invite for that email was refreshed). Invitations expire after 7 days.
If the firm's mfa_enforced is true (the default) and the caller is an owner or admin, this route also requires a two-factor session. A long-lived API token has none, so it gets 403 AUTH_050 unless the firm has mfa_enforced set to false.
Pending invitations
curl "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/invitations?type=all&status=pending" \
-H "Authorization: Bearer dz_your_token_here"Admin only. type is client, staff or all (default); status filters on the invitation status. Returns {"invitations": [...], "total": n}, newest first. Each item has id, type, email, role, status, reason (for pending ones: new_user, needs_clerk_signup or clerk_unavailable), business_id (client invites), expires_at, last_sent_at, email delivery stamps (email_delivered_at, email_opened_at, email_bounced_at, email_bounce_reason, email_complaint_at) and, for staff invites, the queued business_ids, assign_all_firm_businesses and team_id.
POST /advisory-firms/{firm_id}/invitations/{invitation_id}/resend re-sends a pending invitation with a fresh link (the old link stops working) and expiry 7 days out; the response has email_sent and new_expires_at. DELETE /advisory-firms/{firm_id}/invitations/{invitation_id} cancels a pending one. Both return 400 for an invitation that is no longer pending and 404 for an unknown id.
API access per person
curl -X PATCH "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/users/USER_ID/api-access" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"api_access_enabled": false}'Admin only. The switch is on by default and can only restrict: turning it on grants nothing, because DayZero still approves API access for each person. Returns {"user_id", "api_access_enabled"}.
Sidebar menu
GET and PUT /advisory-firms/{firm_id}/users/{user_id}/menu read and set which app sections a staff member can reach. PUT is admin only with {"business_id": null or "<id>", "visible_sections": ["..."] or null}: a null business_id sets the firm-wide default, a business id sets an override for that client (the person must be assigned to it directly, not only through a team). visible_sections: null clears the menu (all sections) and [] grants none. The menu is enforced on the server: a section outside it returns 403. GET is open to admins for anyone and to a member for their own menu, and returns visible_sections, menu_enforced (false for owners and admins, who are never restricted) and overrides (business_id, business_name, visible_sections).
Teams and assignments
A member reaches the union of their direct assignments and the businesses of every team they belong to. Owners and admins reach everything regardless.
| Method | Path | Gate |
|---|---|---|
GET |
/advisory-firms/{firm_id}/teams |
member (counts only) |
POST |
/advisory-firms/{firm_id}/teams |
admin, {"name", "description"} |
GET |
/advisory-firms/{firm_id}/teams/{team_id} |
admin (full member and business lists) |
PATCH |
/advisory-firms/{firm_id}/teams/{team_id} |
admin, name and/or description |
DELETE |
/advisory-firms/{firm_id}/teams/{team_id} |
admin |
POST |
/advisory-firms/{firm_id}/teams/{team_id}/members |
admin, {"user_id"} (owner, admin or member of the firm) |
DELETE |
/advisory-firms/{firm_id}/teams/{team_id}/members/{user_id} |
admin |
POST |
/advisory-firms/{firm_id}/teams/{team_id}/businesses |
admin, {"business_id"} (an active client) |
DELETE |
/advisory-firms/{firm_id}/teams/{team_id}/businesses/{business_id} |
admin |
A team in the list has id, firm_id, name, description, created_by, member_count, business_count, created_at, updated_at; the detail adds members and businesses. Team names are unique within a firm. Duplicate names and duplicate members or businesses are not translated into a clean 409 today, so list first and avoid re-adding. Deleting a team removes only the access it granted.
Direct assignments
Assignments exist only for member staff (400 for owners, admins and clients) and only for active clients.
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/users/USER_ID/assignments" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"business_id": "019ab37c-b051-7000-8000-000000000001",
"engagement_type": "bookkeeping",
"start_date": "2026-01-01",
"notes": "Monthly close"
}'engagement_type is free text (for example bookkeeping, tax_preparation, audit, advisory); start_date and end_date are YYYY-MM-DD. All of these routes are admin only:
GET .../users/{user_id}/assignmentslists them ({"items": [...]}, each withid,user_id,business_id,firm_id,assigned_by, the engagement fields, timestamps).PATCH .../assignments/{assignment_id}updates the engagement fields;DELETEremoves it. A duplicate assignment is400.PUT .../users/{user_id}/assignmentswith{"business_ids": [...]}replaces all of a member's assignments.GET .../users/{user_id}/accessible-businessesshows the resolved access: each item hasid,name,source(all,direct,teamorboth) andsource_details(such asteam:Tax Team). Admins can read anyone's; other members only their own.
Client businesses and the client cap
List, link and unlink
curl "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/businesses?limit=50" \
-H "Authorization: Bearer dz_your_token_here"Any member; scoped callers see only their businesses. Paginated (items, total, next_cursor, has_next, ...); archived businesses are left out. Each item carries the business fields (id, name, mailbox, is_demo, accounting_basis, plaid_connections, sync, ...).
POST /advisory-firms/{firm_id}/businesseswith{"business_id", "notes"}links an existing business (admin). You must also hold an owner or admin role on that business, so naming a UUID is not enough (403). Already linked is400VALIDATION_058. A business billed under a partner agreement is refused with400VALIDATION_489, and this route has no override.DELETE /advisory-firms/{firm_id}/businesses/{business_id}unlinks it, revokes every client-portal grant on it and lowers the client count.- To create a new client under the firm, call
POST /api/v1/businesseswithfirm_id(optional if you staff only one firm). Firm staff only (memberor above). PATCH /advisory-firms/{firm_id}/businesses/accounting-basissets the default basis reports open on for several clients at once:
curl -X PATCH "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/businesses/accounting-basis" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"business_ids": ["019ab37c-b051-7000-8000-000000000001"], "accounting_basis": "cash"}'business_ids holds 1 to 500 ids, accounting_basis is cash or accrual, admin only. Every id must be an active client or the whole batch fails (400, nothing changes). Returns {"updated": n}.
The client cap
The firm plan limits how many active clients a firm can have:
firm_plan_tier |
Client limit |
|---|---|
firm_starter |
5 |
firm_professional |
25 |
firm_enterprise |
unlimited |
Only active, linked businesses count. Archived businesses and credit sandbox copies do not. The cap is checked when you link a business and when you create one with a firm_id. Going over returns 400 BILLING_018 ("You've reached the client limit for your firm plan. Upgrade your plan to add more clients."), with the seat count in the detail, for example This firm is using 5 of 5 client seats. DayZero staff are exempt when linking, and firms that are not on an advisory tier (self-directed or managed) have no cap.
Read the current usage with a client business in x-business-id:
curl "https://api.ondayzero.com/api/v1/billing/firm/client-usage" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"{
"firm_id": "019ab37c-f123-7000-8000-000000000001",
"is_advisory_firm": true,
"firm_plan_tier": "firm_starter",
"active_clients": 4,
"client_limit": 5,
"seats_remaining": 1,
"can_add_client": true,
"tiers": [
{"value": "firm_starter", "client_limit": 5},
{"value": "firm_professional", "client_limit": 25},
{"value": "firm_enterprise", "client_limit": null}
]
}For a business with no firm, is_advisory_firm is false and can_add_client is true. This route belongs to the billing router, which requires the billing permission; among firm roles only owner has it, so admins get 403. POST /api/v1/billing/firm/change-tier with {"firm_plan_tier": "firm_professional"} moves the firm between tiers; a downgrade below the current client count is refused with BILLING_018. It sits behind the same billing permission and the two-factor gate above, so use the web app for plan changes.
Client portal users
Client users have the client role and read-only access to the businesses granted to them.
| Method | Path | Gate |
|---|---|---|
GET |
/advisory-firms/{firm_id}/clients?search= |
member (scoped); {"items": [{id, email, first_name, last_name, photo_url, businesses: [{id, name}]}], "total"} |
POST |
/advisory-firms/{firm_id}/businesses/{business_id}/invite-client |
admin; body {"email", "first_name", "last_name", "message"} |
POST |
/advisory-firms/{firm_id}/clients/{user_id}/businesses |
admin; body {"business_id"} |
DELETE |
/advisory-firms/{firm_id}/clients/{user_id}/businesses/{business_id} |
admin |
GET |
/advisory-firms/{firm_id}/clients/{user_id}/businesses |
member (scoped); {"items": [{id, business_id, business_name, granted_by, created_at}]} |
invite-client works like a staff invite (added, invited or resent, 7-day expiry, resend and cancel through the invitation routes above) and requires the business to be an active client. Granting access to a user who is not yet in the firm adds them as a client; granting to a staff member is refused (403 AUTH_002), and a duplicate grant or revoking a grant that does not exist is 400.
Firm templates
A template is a standard you define once and deploy to many clients. There are three kinds, and a template's kind cannot change after creation. Reading needs a staff role; creating, editing, deleting and deploying need owner or admin.
kind |
payload |
What deploying does |
|---|---|---|
coa |
Non-empty list of ledger objects, each with at least name and type; deploy also needs a numeric sort_code per row. Optional: sub_type, debit_credit, report_cash_flow, editable, description |
Applies the chart of accounts to the client |
bank_rules |
Non-empty list of rules, each with name and a non-empty conditions list, plus actions and optionally description, priority (default 200), is_enabled, match_logic (all or any) |
Creates each rule in the client |
review_checklist |
Non-empty list of steps, each with id and label |
Records the steps against the client as an audit trail; the close checklist itself still uses its built-in steps |
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/templates" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"kind": "coa",
"name": "Restaurants COA",
"description": "Standard chart for restaurant clients",
"payload": [
{"name": "Food Sales", "type": "revenue", "sub_type": "operating_revenues", "debit_credit": "credit", "sort_code": 4000},
{"name": "Food Costs", "type": "expense", "sub_type": "cost_of_goods_sold", "debit_credit": "debit", "sort_code": 5000}
]
}'name is 1 to 255 characters and unique per firm and kind (409 CONFLICT_089); description is up to 1,000. An unknown kind or a payload that does not match its kind is 400 VALIDATION_321. Account type values are asset, liability, equity, revenue, expense (see Ledgers). The template comes back with id, advisory_firm_id, kind, name, description, payload, created_by, created_at, updated_at.
Other routes: GET /advisory-firms/{firm_id}/templates?kind= (returns {"templates": [...], "count": n}), GET and PATCH and DELETE /advisory-firms/{firm_id}/templates/{template_id} (PATCH takes name, description, payload; the payload is re-validated for the template's kind; DELETE returns {"status": "deleted", "template_id"} and does not undo earlier deployments).
Deploy
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/templates/TEMPLATE_ID/deploy" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"business_ids": ["019ab37c-b051-7000-8000-000000000001", "019ab37c-b051-7000-8000-000000000002"]}'business_ids must hold at least one id. Every id must be an active client of the firm (400 VALIDATION_322 otherwise) and, for scoped callers, inside their access (403). Clients are processed one at a time; one failing never stops the rest, and each attempt is stored as a deployment record.
{
"template_id": "019ab37c-aaaa-7000-8000-000000000001",
"kind": "coa",
"applied": [
{"business_id": "019ab37c-b051-7000-8000-000000000001",
"summary": {"kind": "coa", "created": 12, "updated": 3, "skipped": 4, "deactivated": 2,
"skipped_names": ["Accounts Receivable"], "deactivated_names": ["Old Misc"],
"failed": [], "total": 15}}
],
"failed": [{"business_id": "019ab37c-b051-7000-8000-000000000002", "error": "..."}],
"skipped": [],
"applied_count": 1,
"failed_count": 1,
"skipped_count": 0
}Per kind, summary means:
coamatches rows to existing accounts bysort_code. Protected accounts (system accounts, required accounts, bank-linked accounts) are never touched and are counted inskipped. Other matching accounts are updated, new numbers are created, and existing custom accounts whose number is not in the template are set to inactive (deactivated). Individual bad rows (missing or non-numericsort_code, duplicates, conflicts) are listed infailedwithrow,name,sort_code,reason, while the rest still apply. Deploy a full chart, not a fragment.bank_rulesreturnscreated,skipped(a rule with that name already exists, so redeploying is safe) anderrors(rule,error). IDs insideactions(ledger, vendor, customer, tags) are validated against each target business, so use actions that make sense for every client you deploy to. A business with rule errors still appears underapplied.review_checklistreturnsstep_countandstep_ids.
The top-level skipped list is reserved and empty for the current kinds; read the counts inside each summary.
GET /advisory-firms/{firm_id}/templates/{template_id}/deployments (staff) returns {"deployments": [...], "count": n}, each with id, template_id, business_id, status (pending, applied, failed, skipped), summary, error, applied_at, applied_by.
Entity groups
An entity group is a named set of related clients (for example a holding company and its subsidiaries). Groups scope consolidated reports and other multi-entity features to a subset instead of the whole firm. Members must be active clients of the firm.
| Method | Path | Gate |
|---|---|---|
GET |
/advisory-firms/{firm_id}/entity-groups |
member (scoped) |
POST |
/advisory-firms/{firm_id}/entity-groups |
admin |
GET |
/advisory-firms/{firm_id}/entity-groups/{group_id} |
member (scoped) |
PATCH |
/advisory-firms/{firm_id}/entity-groups/{group_id} |
admin |
DELETE |
/advisory-firms/{firm_id}/entity-groups/{group_id} |
admin (members are not affected) |
POST |
/advisory-firms/{firm_id}/entity-groups/{group_id}/businesses |
admin, {"business_id"} |
PUT |
/advisory-firms/{firm_id}/entity-groups/{group_id}/businesses |
admin, {"business_ids": [...]} replaces the membership |
DELETE |
/advisory-firms/{firm_id}/entity-groups/{group_id}/businesses/{business_id} |
admin |
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/entity-groups" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Holdings",
"description": "Parent and operating subsidiaries",
"group_type": "consolidation",
"primary_business_id": "019ab37c-b051-7000-8000-000000000001",
"business_ids": ["019ab37c-b051-7000-8000-000000000002"]
}'name (1 to 255 characters) is unique within the firm (409). group_type is a free tag up to 30 characters, default consolidation. primary_business_id is the optional parent entity and is added to the members automatically. A business that is not an active client is 400. The list returns {"items": [...]}; a group has id, firm_id, name, description, group_type, primary_business_id, created_by, member_count, businesses (id, name), created_at, updated_at. For scoped callers, a group shows only the members they can reach and is hidden when none remain. Removing the primary business, or replacing members without it, clears primary_business_id.
Consolidated reports
Two roll-ups across all active clients (or one entity group). Open to any member; scoped callers are summed over only the businesses they can reach. Money is integer cents.
curl "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/consolidated-reports/pnl?start_date=2026-01-01&end_date=2026-03-31&entity_group_id=GROUP_ID" \
-H "Authorization: Bearer dz_your_token_here"curl "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/consolidated-reports/balance-sheet?as_of_date=2026-03-31&apply_eliminations=true" \
-H "Authorization: Bearer dz_your_token_here"| Route | Query | Notes |
|---|---|---|
GET .../consolidated-reports/pnl |
start_date, end_date (required, YYYY-MM-DD, start not after end), entity_group_id |
Always an uneliminated sum |
GET .../consolidated-reports/balance-sheet |
as_of_date (required), apply_eliminations (default false), entity_group_id |
Optionally nets out intercompany positions |
An unknown entity_group_id is 404; a bad date is 400. With no businesses in scope you get zeros and business_count: 0.
P&L fields: revenue_cents, expenses_cents, net_income_cents, the breakdown maps revenue_breakdown, expense_breakdown, cogs_breakdown, other_income_breakdown, other_expense_breakdown (account name to cents), business_count, businesses_with_teal, businesses_with_local, source, transaction_count, categorized_count, uncategorized_count, basis, basis_by_business. source is gl when the books have figures for the period. When the whole roll-up has no ledger activity, the figures are built from bank transactions instead: categorized_transactions if at least half of them are categorized, otherwise transaction_estimate. Treat estimates as directional. failed_business_count counts clients that could not be read.
Balance sheet fields: assets_cents, liabilities_cents, equity_cents, asset_breakdown (current_assets, fixed_assets), liability_breakdown (current_liabilities, long_term_liabilities), equity_breakdown, is_balanced, business_count, eliminations_applied, elimination_total_cents, elimination_count, basis, basis_by_business.
Eliminations. Confirming a cross-entity pair in cleanup (below) books due-from on one entity and due-to on the other, so a raw sum counts that money on both sides. With apply_eliminations=true, the total of open (pending) elimination entries dated on or before as_of_date, and whose two entities are both in the report's scope, is subtracted from both assets and liabilities. Equity is unchanged.
Basis. Each business is reported on its own default accounting_basis (accrual when unset); basis is accrual, cash or mixed, and basis_by_business names each one. On the P&L these fields are reliable. On the balance sheet endpoint they currently come back as their defaults (accrual and an empty map) even when clients use cash basis, so do not read the basis from that response. There is no basis query parameter on either route; set each client's default (see the bulk basis route above).
Intercompany cleanup
The cleanup engine finds equal-and-opposite transfers between related entities, classifies legs that are missing, books the results and learns from each decision. The firm-level pieces are the Account Master Map, cleanup sessions, match candidates, resolution batches, settlements and balances. All amounts are integer cents.
Access: reads are open to any member and filtered to their businesses; writes need firm staff (member or above) and are limited to the businesses in the caller's scope. The resolution history and the workpaper PDF are staff-only.
Account Master Map
GET /advisory-firms/{firm_id}/account-map returns {"entries", "count", "unmapped_count"}. It first adds an entry for every connected bank or card account across the firm's clients that has none, with ingestion coverage computed from transactions. Each entry has id, business_id, account_id, entity_role, display_name, last4, account_type, in_our_data, coverage_start, coverage_end, known_description_strings, notes, is_external_stub. entity_role is one of unassigned, operating, holding, fund, parent, external_related, other; unmapped_count counts entries still other. Roles are the one thing a person must set.
PATCH .../account-map/{entry_id}updatesentity_role,display_name,last4,account_type,notes,known_description_strings(an invalid role is400VALIDATION_344).POST .../account-mapregisters an external account you do not ingest, for example a parent's card:{"display_name", "entity_role" (default parent), "business_id", "last4", "account_type", "notes", "known_description_strings"}.DELETE .../account-map/{entry_id}removes an external stub only. Linked entries reseed from the connected account (400VALIDATION_345).
Sessions
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/cleanup-sessions" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"date_start": "2025-01-01", "date_end": "2025-12-31", "date_tolerance_days": 3}'date_start and date_end are required (end not before start). business_ids defaults to every active client in your scope; an id outside the firm or your scope is 400 VALIDATION_346. date_tolerance_days is 0 to 14 (default 3); amounts must match to the cent, the tolerance only limits how far apart the dates may be. The response is {"session_id", "status", "workflow_id"} where status is started (a background run) or completed_inline (it ran during the request). Poll GET .../cleanup-sessions/{session_id}:
{
"id": "019ab37c-cccc-7000-8000-000000000001",
"advisory_firm_id": "019ab37c-f123-7000-8000-000000000001",
"business_ids": ["019ab37c-b051-7000-8000-000000000001", "019ab37c-b051-7000-8000-000000000002"],
"date_start": "2025-01-01",
"date_end": "2025-12-31",
"date_tolerance_days": 3,
"status": "awaiting_review",
"run_count": 1,
"pairs_proposed": 42,
"pairs_confirmed": 0,
"orphans_proposed": 7,
"orphans_resolved": 0,
"total_cents_cleared": 0,
"total_cents_in_scope": 18450000
}status is pending, running, awaiting_review, completed or failed (a failure has error). GET .../cleanup-sessions lists them ({"sessions", "count"}). POST .../cleanup-sessions/{session_id}/run re-runs matching: confirmed pairs drop out of the pool, so each run surfaces the next layer.
Candidates and resolving
GET .../cleanup-sessions/{session_id}/candidates returns candidates largest amount first, cursor paginated (limit 1 to 200, default 50; cursor; direction next or prev). Filters: case is internal_transfer, parent_intercompany, data_gap or hidden_detail; status is proposed, confirmed, rejected or applied. The list has items, total_count, next_cursor, prev_cursor, has_next, has_prev. Each item has id, cleanup_session_id, case_type, status, amount_cents, date_delta_days, confidence (0 to 100), confidence_factors, leg_a and leg_b (transaction_id, business_id, business_name, date, amount_cents, description, account_name), expected_counterparty (where a missing leg should be) and resolved_at.
GET .../tracker-candidates returns the same candidate items (same case, status, cursor, limit and direction parameters, largest amount first) for pairs the always-on tracker proposed after bank syncs, which belong to no cleanup session.
GET .../candidates/{candidate_id}/context returns the journal lines of both legs (leg_a_lines, leg_b_lines) and similar_pairs (earlier decisions between the same accounts).
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/candidates/bulk-resolve" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"candidate_ids": ["019ab37c-dddd-7000-8000-000000000001"], "action": "confirm"}'candidate_ids holds 1 to 500 ids; action is confirm or reject. Confirming writes to the books: a pair inside one business moves both legs to the transfers ledger; a pair across entities books due-to and due-from in each entity plus a pending elimination; a parent-side leg books due-from-parent on your side only. Each confirmation also feeds rule learning and the account map's known descriptions. The response has results (candidate_id, outcome such as applied, confirmed, rejected or skipped, reason), confirmed_count, rejected_count, skipped_count, missing_ids (not found or outside your scope), rules_learned and resolution_batch_id, the undo handle (null when nothing was resolved). Candidates that are no longer open are skipped.
GET .../resolutions lists batches (batch_id, candidate_count, confirmed_count, rejected_count, confirmed_cents, resolved_at, resolved_by). POST .../resolutions/{batch_id}/undo unwinds the bookings, restores the original ledgers, removes the eliminations and reopens the candidates as proposed; it returns reopened_count, lines_restored, eliminations_removed. Learned rules and description strings are not reversed.
Also: GET .../access-requests (optional session_id) groups parent-side orphans by expected counterparty account (expected_account_name, candidate_count, total_amount_cents, candidate_ids); GET .../cleanup-badge returns {"proposed_count": n}; GET .../cleanup-sessions/{session_id}/workpaper streams an audit PDF (application/pdf, staff only).
Settlements and balances
GET .../settlement-proposals derives pairwise net positions from open eliminations, largest first: payer_business_id, payer_business_name, payee_business_id, payee_business_name, net_cents, open_item_count. POST .../settlements books the netting entry once the repayment transfer has been matched:
curl -X POST "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/settlements" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"payer_business_id": "019ab37c-b051-7000-8000-000000000002",
"payee_business_id": "019ab37c-b051-7000-8000-000000000001",
"amount_cents": 4200000,
"settlement_date": "2026-03-31",
"memo": "Q1 intercompany repayment"
}'amount_cents must be positive and memo is up to 500 characters. Both businesses must be in the firm and your scope. The route refuses to net more than both gross due-to and due-from balances support (400 VALIDATION_348); confirm the repayment pair first. It returns journal_entry_ids (one netting entry per business), eliminations_posted and amount_cents.
GET .../intercompany-balances returns rows (business_id, business_name, due_from_cents, due_to_cents, net_cents) plus total_due_from_cents and total_due_to_cents. A mismatch between the totals points to a counterparty outside your data or unfinished cleanup.
Firm reports and the client overview
Cross-client reports for the firm. Open to any member; scoped callers see only their businesses.
curl "https://api.ondayzero.com/api/v1/advisory-firms/FIRM_ID/reports" \
-H "Authorization: Bearer dz_your_token_here"GET /advisory-firms/{firm_id}/reports lists the report types (reports, and the same items grouped in by_category; each has report_name, title, description, category). Run one with GET /advisory-firms/{firm_id}/reports/{report_name}; the response is {"report": {...}} and the shape depends on the report. An unknown name is 400 and the message lists the valid ones.
report_name |
Category | Shows |
|---|---|---|
client_portfolio_overview |
Portfolio Management | Per-client activity, integrations, health score |
client_activity_summary |
Portfolio Management | Recent activity across clients |
bank_connection_health |
Data Quality | Disconnected, stale or errored bank connections |
integration_status |
Data Quality | Integration status per client |
data_quality_scorecard |
Data Quality | Quality score per client |
uncategorized_transactions |
Work Management | Clients with uncategorized or unreviewed transactions |
missing_receipts |
Work Management | Expenses needing a receipt under each client's policy, by dollars outstanding |
reconciliation_status |
Work Management | Reconciliation status per account |
work_queue |
Work Management | Prioritized work items across clients |
tax_calendar |
Compliance | Deadlines from each client's fiscal year end |
ar_ap_aging_summary |
Financial Overview | AR and AP aging across clients |
Shortcuts GET .../reports/portfolio-overview and GET .../reports/work-queue return the same data as client_portfolio_overview and work_queue. The portfolio report has report_name, firm_name, generated_at, a summary (total_clients, active_clients, stale_clients, average_health_score) and clients. Each client row has id, name, transaction_count, last_transaction, user_count, plaid_connections, ledger_count, has_shopify, has_stripe, has_mailbox, health_score (0 to 100) and activity_status (active within 7 days of the last transaction, recent 30, stale 90, inactive beyond, no_data).
Client overview. The "firm client overview" assistant tool is get_firm_client_overview on the MCP server. It runs these same reports (client_portfolio_overview, ar_ap_aging_summary, work_queue, reconciliation_status, client_activity_summary, data_quality_scorecard, uncategorized_transactions) with the same scoping, so over REST use the routes above. Firm-wide bill totals are a business-scoped route, GET /api/v1/payment-advisory/firm-summary with a client in x-business-id (needs the AP automation add-on), and are not covered here.
POST /advisory-firms/{firm_id}/ai-insights (member) returns {"insights": [...]} generated from up to 30 of your active clients. Each insight has type (attention, workload, anomaly or stale_data), title, description, severity (info, warning or critical), business_id and business_name. It is generated per call and not cached.
Notifications and Slack
- Slack.
GET /advisory-firms/{firm_id}/slack/status(member) returnsconnected,webhooksandgroups. Admin routes:PUT .../slackwith{"slack_webhook_url", "channel_name", "preferences"}(an empty or null URL disconnects),PUT .../slack/{webhook_id}/preferenceswith{"preferences": {"<category>": true}},POST .../slack/{webhook_id}/test(a legacyPOST .../slack/testtests the first webhook), andDELETE .../slack/{webhook_id}. - Notification policy.
GETandPUT /advisory-firms/{firm_id}/preferences/notifications/topicsread and replace the firm-wide per-topic channel policy for staff, andGETandPUT /advisory-firms/{firm_id}/businesses/{business_id}/preferences/notifications/topicsdo the same for one client business (it must be an active client in your scope). Admin only. The body is{"overrides": {"<topic>": {"<channel>": true}}}; an empty map for a topic clears it, an empty top-level map clears all. The response also listsavailable_channelsandavailable_topicsso you can build the grid from it.
Approval policy
The firm routes expose no approval-policy settings. AP bill approval workflows (thresholds and routing) are configured per client business under /api/v1/ap-approval, require the AP automation add-on, and are documented in AP Workflows. What a firm controls centrally is who can act (roles, assignments, menus) and the notification policy above.
Errors at a glance
| Status | code |
Meaning |
|---|---|---|
400 |
BILLING_018 |
Client cap reached or a downgrade would drop below the current client count |
400 |
VALIDATION_057 |
User is already in the firm |
400 |
VALIDATION_058 |
Business is already linked to the firm |
400 |
VALIDATION_321 / VALIDATION_322 |
Template payload invalid for its kind / deploy target is not a firm client |
400 |
VALIDATION_344 to VALIDATION_348 |
Account map role or delete rule, cleanup scope, candidate state |
400 |
VALIDATION_464 |
Cannot remove or demote the last owner |
400 |
VALIDATION_489 |
Business is billed under a partner agreement and cannot be linked here |
403 |
AUTH_002 |
Not a member, or the role is too low for this route |
403 |
AUTH_012 |
The role lacks a required permission (for example manage_users on staff changes, or billing on the billing routes) |
403 |
AUTH_048 |
Token scope blocks the call, or only an owner may change an owner |
403 |
AUTH_050 |
Two-factor session required (staff invites for MFA-enforcing firms) |
403 |
AUTH_057 |
Needs a recent identity check an API token cannot provide (firm delete) |
403 |
AUTH_071 |
Creating a firm is a DayZero operator action |
403 |
AUTH_074 |
The firm is archived |
404 |
NOT_FOUND_089, NOT_FOUND_095, NOT_FOUND_096, NOT_FOUND_097 |
Template, account map entry, cleanup session or candidate not found |
409 |
CONFLICT_089 |
Template name already used for that kind |
Not covered
Billing and subscription management beyond the client cap, the per-client invitation flows that happen in the client's own books, document requests and client messaging, and DayZero's internal admin tools for firms.