Businesses and Users
A business is the tenant every other resource belongs to: its ledgers, transactions, invoices and connections are isolated from every other business. A user is a person who can reach one or more businesses, either through a direct membership or through an advisory firm. This guide covers the public /businesses, /onboarding and /users routes. Tokens and business-scoped tokens are in Authentication; the response envelope is in Error Handling.
The app's view of the same settings is in Business Management and Profile.
How requests are scoped
| Route | x-business-id |
Notes |
|---|---|---|
GET /api/v1/businesses, POST /api/v1/businesses |
Not used | User-level routes. They never need a business header |
POST /api/v1/businesses/validate-coa, POST /api/v1/businesses/generate-coa, GET /api/v1/businesses/mailbox |
Not used | User-level helpers for creating a business |
Every other /api/v1/businesses/... route |
Required | The header names the business the request acts on |
/api/v1/onboarding/..., /api/v1/users/... |
Not used | User-level. GET /users can take the header as a filter (see below) |
Routes with {business_id} in the path (GET/PUT/DELETE /businesses/{business_id}, the deletion and export routes, the CFO portal and the managerial key mapping routes) require the path ID to be the same as x-business-id. The header decides the business; a path ID that differs is refused with 403, never silently switched. Send the same UUID twice.
Business-scoped writes also need the business to be in an active trial or on an active subscription. Otherwise they return 403 BILLING_001. User-level routes are not subject to this check.
Roles and permissions
Each route requires one permission. A user's role in the business decides which permissions they hold.
| Role | Permissions held |
|---|---|
owner |
read, write, delete, admin, manage_users, billing |
admin |
read, write, delete, admin, manage_users |
member |
read, write |
viewer |
read |
Access through an advisory firm maps differently. Firm owner and admin behave like business owner and admin. A firm member (staff) holds read, write, delete and admin on the clients they are assigned to, but not manage_users. A firm client and a firm viewer hold read only.
When a route does not name a permission, the HTTP method decides: GET needs read, POST, PUT and PATCH need write, DELETE needs delete. A missing permission returns 403 AUTH_012. A token scoped read_only is also refused on every non-GET route (403 AUTH_048).
The legacy role value accountant is still accepted by the member routes, but it holds no permissions. Use owner, admin, member or viewer.
Businesses
List the businesses you can access
curl "https://api.ondayzero.com/api/v1/businesses?user_id=YOUR_USER_ID&limit=20" \
-H "Authorization: Bearer dz_your_token_here"The list is cursor-paginated (see Pagination) and always limited to businesses the caller can reach, directly or through a firm. Archived businesses and businesses reached only through an archived firm are not listed.
The route requires at least one filter; a call with none is rejected with 422. The filters are id, name (partial, case-insensitive), teal_instance (a UUID), stripe_account_id, shopify_shop_domain and user_id. user_id may only be your own ID (another user's returns 403). To find your own ID, call GET /api/v1/users?email=you@example.com with your own email.
Each item has the fields in the table below, plus integration data (plaid_connections, slack_connected, sync).
| Field | Notes |
|---|---|
id, firm_id, name, name_aliases, logo_url |
firm_id is the advisory firm the business belongs to |
default_currency |
USD, CAD, AUD, EUR or GBP |
accounting_basis |
cash or accrual. null means accrual |
tax_year_end_month |
1 to 12. 12 is a calendar year |
start_date |
Founding date. Informational |
entries_start |
Where bookkeeping imports begin (Teal engine only, see below) |
mailbox |
Prefix of the bill-forwarding address |
inventory_mode, ap_approval_enabled, ap_due_date_basis, ar_subledger_mode, auto_rename_counterparties, auto_link_new_counterparties, stripe_payout_sibling_matching, agent_close_business_day |
Per-business switches, all editable with PUT /businesses/{business_id} |
GET /api/v1/businesses/{business_id} returns one business in the same shape. It needs read.
Create a business
Creating a business is limited to advisory firm staff (firm owner, admin or member) and DayZero operators. Anyone else, including a solo business owner or a firm client, gets 403 AUTH_048 (insufficient permissions).
curl -X POST "https://api.ondayzero.com/api/v1/businesses" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Coffee Roasters",
"default_currency": "USD",
"tax_year_end_month": 12,
"accounting_basis": "accrual",
"firm_id": "YOUR_FIRM_ID"
}'The response is 201 with the new business under data.
| Field | Required | Notes |
|---|---|---|
name |
Yes | |
default_currency |
No | Default USD |
tax_year_end_month |
No | 1 to 12, default 12 |
accounting_basis |
No | cash or accrual. Omit for accrual |
mailbox |
No | Unique bill-forwarding prefix. Generated from the name when omitted |
firm_id |
Conditional | Optional if you are staff of exactly one firm (that firm is used). Required if you belong to more than one (400) |
user_id |
No | Defaults to you. Only a DayZero operator can name someone else |
coa_template_id or custom_coa_template |
No | Mutually exclusive. Validate a custom chart with POST /businesses/validate-coa and draft one with POST /businesses/generate-coa |
entries_start |
No | YYYY-MM-DD. Only meaningful for a Teal-engine business |
create_teal_instance, teal_instance |
No | Teal engine only. By default the business uses the DayZero local engine, and a full chart of accounts is seeded for it |
The body rejects unknown fields. It also rejects stripe_customer_id, stripe_subscription_id and subscription_items (server-provisioned), and billing fields (trial_ends_at, billing_status, plan_tier, active_addons) are refused with 403 unless the caller is DayZero staff. A new business starts a 30-day trial.
Creating a business does not write a membership for you. You reach it through your firm. Other errors: 409 CONFLICT_012 (mailbox in use), 409 CONFLICT_011 (the named owner already has a business with that name), 400 BILLING_018 (the firm is at its plan's client limit).
Before choosing a mailbox, check it is free:
curl "https://api.ondayzero.com/api/v1/businesses/mailbox?mailbox=acme" \
-H "Authorization: Bearer dz_your_token_here"200 with {"unique": true} means it is available; 409 CONFLICT_010 means it is taken, 400 VALIDATION_036 means it was blank. The check is rate-limited per user (60 per minute).
Update a business
PUT /api/v1/businesses/{business_id} needs admin. Send only the fields you want to change; the body rejects unknown fields (billing and integration fields cannot be set here).
curl -X PUT "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"accounting_basis": "cash", "default_currency": "CAD", "tax_year_end_month": 6}'The response is the updated business (same shape as GET). Editable fields: name, name_aliases (up to 20, replaces the list), logo_url, default_currency, tax_year_end_month, start_date, accounting_basis, mailbox, inventory_mode (cpg or finished_goods), ap_approval_enabled, ap_due_date_basis (invoice_date or received_date), ar_subledger_mode (off or per_customer), auto_rename_counterparties, auto_link_new_counterparties (on by default: new customers and vendors are linked to the counterparty registry automatically; turn it off to keep new ones unlinked until a person links them, existing links are untouched), stripe_payout_sibling_matching and agent_close_business_day (the business day of the month, 1 to 23, or -1 for the last, on which the close of the month that just ended starts and close.window_opened is sent; null or 0 turns it off; see Webhooks). A mailbox already used elsewhere is 409 CONFLICT_012.
Accounting basis is the basis financial reports open on. It does not change how entries are posted. A report can still be run on the other basis with its own basis parameter (see Reports). For a Teal-engine business the change is also pushed to Teal.
Default currency is a single base currency per business. The API stores whatever string you send, so send one of USD, CAD, AUD, EUR or GBP. Changing it afterwards does not convert or relabel amounts already on the books.
Turning ar_subledger_mode to per_customer links existing AR - <Customer> ledgers to their customers immediately. Entries already posted move only when you run the AR backfill separately.
Other business settings
| Route | Permission | Notes |
|---|---|---|
PUT /api/v1/businesses/entries-start |
admin |
Body {"entries_start": "2026-01-01"}. Teal-engine businesses only; a local-engine business gets 400 |
POST /api/v1/businesses/{business_id}/mailbox |
admin |
Mints a mailbox for a business that has none. Idempotent: an existing mailbox is returned unchanged with created: false. Returns mailbox, inbox_email, created |
GET /api/v1/businesses/{business_id}/sync |
read |
Teal sync status: status (syncing, completed, nothing_to_sync), last_synced, sync_in_progress |
Sidebar navigation preferences
A business can hide sidebar items it does not use. This is a preference, not a permission: hidden items stay reachable by URL and entitlement gating is unchanged. The setting applies to every user of the business.
curl "https://api.ondayzero.com/api/v1/businesses/nav-preferences" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"GET (needs read) returns hidden (a list of nav item IDs such as workspace:inventory), an optional profile (summary, source, updated_at) and version. An empty hidden list means everything entitled is shown.
curl -X PUT "https://api.ondayzero.com/api/v1/businesses/nav-preferences" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"hidden": ["workspace:inventory"], "profile": {"summary": "Service business, no inventory"}}'PUT needs admin and replaces the whole list (it does not merge); send [] to restore everything. Blank and duplicate IDs are dropped, and more than 200 IDs is 400. The server does not check IDs against a catalog; the IDs come from the app's navigation. profile.source defaults to manual.
POST /api/v1/businesses/nav-preferences/suggest (needs write) asks the AI which items to hide. Send description (1 to 2000 characters) and items, a list of {id, label, description, feature_tags} (1 to 100 entries), plus optional signals. It returns hide ([{id, reason}], only IDs you sent) and profile_summary. Nothing is saved; apply the result with PUT.
Saved table views
Saved filters, sort and column layouts for the app's data tables are stored per user and per business: your views in one business are not visible to others, and the same user has separate views in each business.
| Route | Permission | Description |
|---|---|---|
GET /api/v1/businesses/user-preferences/table-views |
read |
All tables: tables (keyed by table key) and version |
GET /api/v1/businesses/user-preferences/table-views/{table_key} |
read |
One table's blob (v, views, defaultViewId) |
PATCH /api/v1/businesses/user-preferences/table-views |
write |
Upsert one or more tables |
curl -X PATCH "https://api.ondayzero.com/api/v1/businesses/user-preferences/table-views" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"version": 3,
"tables": {
"invoices": {
"v": 1,
"defaultViewId": "overdue",
"views": [{"id": "overdue", "name": "Overdue", "state": {"filters": {"status": "open"}}}]
}
}
}'version (or an If-Match: 3 header, which wins) is optimistic concurrency: if it differs from the stored version the call is 409 and nothing is written, so re-read and retry. Omit it to skip the check. A table sent with no views and no default is removed. Limits: 50 views per table, view names cut to 120 characters, 200 tables per request, and a defaultViewId that matches no view is dropped. Because PATCH needs write, a viewer cannot save views.
Data lifecycle: export and deletion
Deleting a business is a two-step, time-delayed process. DELETE only schedules the deletion; the hard delete runs after a grace period (30 days by default). Until then the business stays readable, can be exported and the schedule can be cancelled.
| Stage | Reversible? | What happens |
|---|---|---|
| Deletion scheduled | Yes, until the hard delete starts | Owners are emailed. Background syncs and scheduled jobs stop. Asking again keeps the original date |
| Cancel | Clears the schedule, syncs resume, owners are emailed | |
| Hard delete (nightly sweep after the date) | No | Third-party grants (Plaid, QuickBooks, Xero, Shopify, Square, Finch and others) are revoked. Every business row, stored file version and AI memory is deleted |
Schedule deletion
curl -X DELETE "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"confirm_name": "Acme Coffee Roasters"}'Needs admin. confirm_name must match the business name (trimmed, case-insensitive); a mismatch is 400 VALIDATION_800. The call also demands a recent identity check by a signed-in person, which an API token cannot provide, so a token-authenticated call is refused with 403 AUTH_057 after the name check passes. Schedule deletion from the dashboard (the Data & Deletion area of the business settings). The API stays useful for the read, cancel and export routes below. (confirm_password is still accepted in the body but ignored.)
A successful call returns 202 with the schedule in data.
Check or cancel the schedule
curl "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/deletion" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Any member with read can check. The response has scheduled, deletion_requested_at, deletion_scheduled_for (UTC), deletion_requested_by, grace_days, days_remaining and can_cancel. can_cancel is true only for a caller who holds admin.
curl -X POST "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/deletion/cancel" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Needs admin and returns 200 with the cleared schedule. If nothing is scheduled it is 400 BUSINESS_190; once the hard delete has started it is 409 BUSINESS_191.
Export all business data
curl -X POST "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/data-exports" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"All three export routes need admin. The request returns 202 with the export (export_id, status: "pending"). The export is built in the background as a zip of CSV files, and the requester is emailed a short-lived download link. Asking again while one is running returns the running one.
The zip holds the chart of accounts, journal entries and their lines, transactions, invoices, bills, customers, vendors, a general ledger and a trial balance, plus a README. Documents and attachments are not included. In the raw table files money columns are integer cents; the general ledger and trial balance files are reports and show dollars.
Poll one export, or fetch the newest, to get its state:
curl "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/data-exports/EXPORT_ID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"GET .../data-exports/latest returns the most recent export without a link (404 BUSINESS_192 if there is none). Each export has status (pending, ready, failed, expired), requested_at, completed_at, row_counts, size_bytes, file_expires_at and error. The single-export route adds download_url and download_url_expires_in (seconds; 15 minutes by default) while the status is ready; call it again for a fresh link. The file itself is deleted after 7 days by default (file_expires_at).
Managerial key mappings
Managerial key mappings roll the chart of accounts up into named category sets for your own reporting and joins. A key type is a column of the table (for example managerial_reporting); a mapping is the category one ledger rolls up to under one key type. The table is API-only data: it does not feed any DayZero report. It does not need the CFO Suite add-on.
Every business has two built-in key types, managerial_reporting ("Managerial Reporting") and managerial_summary ("Managerial Summary Reporting"). They always exist and are flagged is_default: true.
curl "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/managerial-key-mappings?only_mapped=true" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"The response has key_types (active ones, in display order), one rows entry per ledger with ledger_id, ledger_name, account_number, ledger_type, sub_type, status and keys ({key_type: category}, null where unmapped), plus total_ledgers and mapped_ledgers. Query parameters: key_type (return one column only; an inactive or unknown one is 400 VALIDATION_462) and only_mapped (drop unmapped ledgers).
Write cells with a bulk upsert. A null or blank value clears the cell; cells you do not mention are untouched; the response is the refreshed table.
curl -X PUT "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/managerial-key-mappings" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"items": [{"ledger_id": "LEDGER_UUID", "key_type": "managerial_reporting", "value": "Software"}]}'value is at most 120 characters. A key_type that is not active is 400 VALIDATION_462; a ledger from another business is 400 NOT_FOUND_014.
| Route | Permission | Description |
|---|---|---|
GET .../managerial-key-mappings/key-types |
read |
Key types with mapping_count |
POST .../managerial-key-mappings/key-types |
write |
Create. Body key (lowercase slug ^[a-z][a-z0-9_]*$, up to 40 characters), label (up to 100), optional description (up to 500) and sort_order (0 to 999, default 100). Duplicate is 409 CONFLICT_092 |
PUT .../managerial-key-mappings/key-types/{key} |
write |
Change label, description, sort_order or is_active |
DELETE .../managerial-key-mappings/key-types/{key} |
delete |
Delete a key type |
GET .../managerial-key-mappings/export.csv |
read |
CSV, one row per ledger and one column per active key type |
(All paths start with /api/v1/businesses/{business_id}.) DELETE is refused with 400 VALIDATION_463 while the key still has mappings. Add ?force=true to delete the key type and its mappings, which cannot be undone; the response reports deleted_mappings. A built-in key type is never removed: deleting it deactivates it (hiding the column and blocking new mappings), and PUT with is_active: true brings it back. An unknown key is 404 NOT_FOUND_108.
CFO portal settings
The CFO portal routes sit under /api/v1/businesses/{business_id}/cfo-portal/.... Every one requires the CFO Suite add-on (403 BILLING_003 without it). A business in its trial has all add-ons unlocked. Their permissions follow the method rules above (GET read, PUT/POST/PATCH write, DELETE delete). A path ID that differs from x-business-id is 403 AUTH_002.
Each section is a GET that returns the same wrapper: business_id, section, generated_at, sources (each with key, label, status of available, empty, partial or unavailable, and message), data and, on statement sections, basis (the business's default accounting basis). If a section fails internally the call still returns 200 with an empty data and an unavailable source, so check sources.
Section path (under /cfo-portal/) |
Query parameters |
|---|---|
summary |
include_series (default false), include_weekly_pl (default true) |
managerial-pl, statements, flux, board-deck |
start_date, end_date |
budget-vs-actual |
budget_id, start_date, end_date |
cash-forecast |
budget_id, apply_overrides, include_projected_replenishment, include_unapproved_ap, net_bills_against_budget |
vendor-spend |
start_date, end_date, group_by (default counterparty), sources |
purchase-orders |
status |
configuration, unit-economics, monthly-close, ar-aging, ap-aging, landed-cogs, shipping-analysis, shopify, reports-documents, debt-schedule, alerts, valuation, benchmarks, recipe-book |
none |
Read and change the configuration
GET .../cfo-portal/configuration returns data.unit_economics (aov_mode, manual_aov_cents, aov_mode_label, shopify_sections) and data.cash_flow_forecast (excluded_budget_ledger_ids).
curl -X PUT "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/cfo-portal/unit-economics/settings" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"aov_mode": "manual", "manual_aov_cents": 4500, "shopify_sections": ["unit-economics", "shipping-analysis"]}'aov_mode is shopify or manual. With manual, manual_aov_cents is required and must be greater than 0 (4500 is $45.00). shopify_sections lists the sections allowed to use Shopify order data; omit it to keep the current list. The response is the refreshed configuration section. Validation failures use the code VALIDATION_266.
curl -X PUT "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/cfo-portal/cash-forecast/settings" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"excluded_budget_ledger_ids": ["LEDGER_UUID"]}'This replaces the list of budget ledgers left out of the 13-week cash forecast, and returns the refreshed cash forecast section. GET .../cfo-portal/po-forecast-settings lists each open purchase order and whether the forecast counts it under inventory or accounts payable.
Recipe Book buckets and mappings
The Recipe Book groups ledgers into managerial buckets for the managerial P&L. This is separate from the key mappings above.
GET .../cfo-portal/bucketslists default and custom buckets (key,label,color,classification,sort_order,is_active,is_default,can_delete).POST .../cfo-portal/bucketscreates a custom bucket:key(lowercase slug, up to 40),label(up to 100),color(green,orange,indigo,accent,gray; defaultgray),classification(gross_revenue,revenue_adjustments,operating_revenue,cogs,cost_of_sales,operating_expense,other_income,other_expense; defaultoperating_expense) andsort_order. A key that matches a built-in bucket is400VALIDATION_265; a duplicate is409CONFLICT_026.PUT .../cfo-portal/buckets/{bucket_key}edits one. Built-in buckets accept display overrides only.DELETE .../cfo-portal/buckets/{bucket_key}deletes a custom bucket, or deactivates a built-in one, and clears every ledger mapping that pointed at it. The built-insrevenue,cogs,software_tech,marketingandgaare locked (400VALIDATION_265). An unknown custom bucket is404NOT_FOUND_070.
curl -X PUT "https://api.ondayzero.com/api/v1/businesses/YOUR_BUSINESS_ID/cfo-portal/recipe-book/managerial-mappings" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"mappings": [{"ledger_id": "LEDGER_UUID", "bucket": "marketing"}]}'A bucket of null clears the ledger's mapping; ledgers you do not mention are untouched. An unknown bucket is 400 VALIDATION_266, and a ledger from another business is 400 NOT_FOUND_014. The response is the refreshed Recipe Book section. PUT .../recipe-book/managerial-builders saves the ledgers behind the cm1, cm2, cash_outflow_debt and cash_outflow_inventory lines (builders: [{key, label, ledger_ids}]), and POST .../recipe-book/auto-assign (no body) asks the AI to map ledgers that have no bucket yet. Existing mappings are never changed, and low-confidence suggestions are dropped, so the response may map only some of the ledgers.
Not covered here
The CFO portal also exposes the cash forecast planner (cash-forecast/timing, one-time-items, versions, overrides, demand-plan), debt schedule instruments, schedules and covenants (debt-schedule/...), the board deck memo and PDF (board-deck/...) and an ask question endpoint. These follow the same scoping, add-on and permission rules; consult the API Reference for their schemas.
Members and roles
Members of a business are managed with business-scoped routes (note there is no {business_id} in the path; the x-business-id header selects the business).
| Route | Permission | Description |
|---|---|---|
GET /api/v1/businesses/users |
read |
List members |
POST /api/v1/businesses/users |
manage_users |
Add an existing user |
PUT /api/v1/businesses/users/{user_id} |
manage_users |
Change role and sidebar visibility |
GET /api/v1/businesses/users/{user_id}/role |
read |
A user's role and menu |
DELETE /api/v1/businesses/users/{user_id} |
manage_users |
Remove from this business |
manage_users is held by business owner and admin and by firm owner and admin. It is not held by firm staff member.
curl "https://api.ondayzero.com/api/v1/businesses/users?include_advisory_users=true&limit=50" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Each item has the user's profile fields (id, email, first_name, last_name, photo_url, onboarded, firm_ids), role, source and visible_sections. source is business_user for a direct member and advisor_user for someone who has access through the business's advisory firm (only returned with include_advisory_users=true). DayZero support staff who added themselves are hidden from the business's own users.
Add a member
curl -X POST "https://api.ondayzero.com/api/v1/businesses/users" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"id": "USER_UUID", "role": "member"}'id is the UUID of a user who already exists (adding a user does not create the account, and nothing is emailed). role defaults to member. The response is 201 with data.user_id, data.business_id and data.role.
| Role | Can do |
|---|---|
owner |
Everything, including billing and managing members |
admin |
Read, write, delete, settings and members; no billing |
member |
Read and write business data; no settings, members or deletes |
viewer |
Read only |
Only an owner (a business owner, a firm owner, or a DayZero operator) can grant owner; an admin trying to is 403 AUTH_048. The user must exist (404) and the business must exist (404).
Change a role or menu
curl -X PUT "https://api.ondayzero.com/api/v1/businesses/users/USER_UUID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"role": "viewer", "visible_sections": ["banking", "accounts_payable"]}'role is required. visible_sections is optional and has three states: omit it to leave the member's menu unchanged, send null to reset to "all sections", and send [] to hide every section (up to 100 non-empty strings). The strings are section keys owned by the app, and this is interface visibility only: it never restricts API access to data.
The response data has user_id, business_id, role and visible_sections. Only people with a direct membership can be changed here. Someone who has access through the advisory firm is 400 VALIDATION_448: manage them in the firm's settings. A user who is not a member is 404 NOT_FOUND_015.
The route is documented as refusing to demote the last owner, but the code only enforces that on removal. Promote the new owner before you demote the old one.
Read a role
GET /api/v1/businesses/users/{user_id}/role returns user_id, business_id, role, visible_sections and menu_enforced. It resolves firm access too: firm staff appear with the business role admin. menu_enforced says whether visible_sections actually binds that person (owners and admins are normally exempt from menus). Call it with your own user ID to get your role and menu in one request.
Remove a member
DELETE /api/v1/businesses/users/{user_id} removes only the membership; the user account stays, and so does their access to other businesses. The business's last direct owner cannot be removed (400 VALIDATION_464). Transfer ownership first. Removing someone who is not a direct member is 404 NOT_FOUND_015.
Users
The /users routes work on profiles, not on business access (use the member routes above for that). They are user-level: no x-business-id is needed.
| Route | Who may call it | Description |
|---|---|---|
GET /api/v1/users |
See below | List users |
GET /api/v1/users/{user_id} |
The user themselves or a DayZero operator | One profile (403 otherwise) |
POST /api/v1/users |
See below | Create your own user row |
PUT /api/v1/users/{user_id} |
The user themselves or a DayZero operator | Update the profile |
DELETE /api/v1/users/{user_id} |
The user themselves or a DayZero operator | Delete the account |
GET/PUT /api/v1/users/me/last-business |
Any signed-in user | The business and firm last selected |
GET /api/v1/users/me/mfa-status |
Any signed-in user | Whether MFA is required and satisfied |
List users
curl "https://api.ondayzero.com/api/v1/users?business_id=YOUR_BUSINESS_ID&search=ana&limit=20" \
-H "Authorization: Bearer dz_your_token_here"Filters: search (name or email), id, email (exact), business_id and firm_id. The x-business-id and x-firm-id headers are accepted in place of business_id and firm_id. A scope is required:
business_idneedsreadon that business.firm_idneeds a firm staff role (owner,adminormember). A firmmembersees the roster withemailset to a placeholder and names removed; firmownerandadminsee everything.- With neither, you may only look up your own email; anything else is
403.
The response is cursor-paginated. Items have id, email, first_name, last_name, photo_url, onboarded, props, created_at, updated_at, firm_ids and, for a business-filtered list, role.
Create and update
POST /api/v1/users (body email, optional first_name, last_name, photo_url, onboarded, props) is the first-login bootstrap. You can only create the row for your own email, and you must belong to an advisory firm or hold admin on a business. A user with that email already existing is 409 CONFLICT_007. Creating a user gives it no access to any business.
curl -X PUT "https://api.ondayzero.com/api/v1/users/YOUR_USER_ID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"first_name": "Ana", "last_name": "Silva"}'Updatable fields are first_name, last_name, photo_url, onboarded and props. The email cannot be changed and privilege fields are refused. props is written as a whole object, not merged, and the app keeps saved table views and the last-selected business in it. Read the profile first and send back the keys you are not changing.
Delete a user
DELETE /api/v1/users/{user_id} permanently deletes the account and its business memberships. Personal data such as AI memories and tokens is purged first. Business data the user created stays with the business. This cannot be undone. Like business deletion it needs a recent identity check that an API token cannot provide, so a token-authenticated call is refused with 403 AUTH_057; use the dashboard. To take someone out of one business only, use DELETE /api/v1/businesses/users/{user_id}.
Last selected business
PUT /api/v1/users/me/last-business with {"business_id": "…", "firm_id": "…"} stores the pair (the business must be one you can access, 403 otherwise); GET returns business_id and firm_id, both null if never set.
Onboarding
The /onboarding routes are the self-service sign-up flow behind the app's Welcome wizard: pick a plan, pay through Stripe Checkout (with a 30-day trial), and DayZero then creates the firm and business. Because Checkout happens in a browser, an integration normally has no reason to call them; they are listed so the flow is not a surprise. All are user-level and need no x-business-id; POST calls return 201.
| Route | Description |
|---|---|
POST /api/v1/onboarding/prepare-checkout |
Validates the choices and returns a Stripe Checkout checkout_url and session_id. Nothing is created yet |
POST /api/v1/onboarding/complete |
Body {"stripe_session_id": "…"}. After Checkout, creates the firm and business and returns firm_id, business_id, user_id |
POST /api/v1/onboarding/upload-url |
Presigned POST policy for a logo (png, jpg, jpeg, gif, webp, svg; up to 5 MB) |
POST /api/v1/onboarding/ai-discover |
The next AI discovery question for the wizard |
POST /api/v1/onboarding/ai-recommend |
Add-on recommendations from the discovery conversation |
GET /api/v1/onboarding/profile |
Saved wizard state, so it can resume after a refresh |
prepare-checkout takes account_type (self-managed, dz-managed or advisory-firm), business_name, mailbox, trial_agreed (must be true), and optionally default_currency (default USD, one of USD, CAD, AUD, EUR, GBP, otherwise 400), tax_year_end_month, selected_addons (up to 50), billing_interval (month or year) and, for advisory firms, firm_name, firm_plan_tier (firm_starter, firm_professional, firm_enterprise) and more. It is 409 if the account has already finished onboarding (CONFLICT_023) or the mailbox is taken (CONFLICT_010). complete is 409 GEN_007 unless the Checkout session is complete with a trialing or active subscription, and 403 if the session belongs to another user.
The routes are rate limited per user and per IP over a five-minute window (429): prepare-checkout 10 and 30, complete 10 and 30, upload-url 20 and 60, ai-discover 20 and 30, ai-recommend 5 and 30. Conversations for ai-discover and ai-recommend hold at most 20 messages, each cut to 1,000 characters.
What is not covered
/businesses also hosts routes that belong to other guides or to the app's own screens: financial statements per business (see Reports), auto-numbering, invoice settings, Plaid transaction settings, Slack, Teal instance tools, chart-of-accounts helpers beyond validate-coa and generate-coa, and linking a Stripe subscription. Counterparty defaults and aliases, notes and the categorization profile are separate route families.
Errors at a glance
| Status | Code | Meaning |
|---|---|---|
400 |
VALIDATION_800 |
confirm_name does not match the business name |
400 |
BUSINESS_190 |
No deletion is scheduled |
400 |
VALIDATION_448 |
The person has firm access; manage them in the firm |
400 |
VALIDATION_464 |
Cannot remove the last owner |
400 |
BILLING_018 |
The firm is at its client limit |
401 |
AUTH_008, AUTH_010 |
Missing x-business-id, or the token cannot reach that business (see Authentication) |
403 |
AUTH_010 |
The {business_id} in the path differs from x-business-id (business, deletion and export routes) |
403 |
AUTH_002 |
Same mismatch on CFO portal and managerial key mapping routes, or acting on another user's profile |
403 |
AUTH_012 |
The role lacks the permission the route needs |
403 |
AUTH_048 |
A read_only token on a write, or an action your role cannot take (for example creating a business as non-staff, or an admin granting owner) |
403 |
AUTH_057 |
The action needs a recent sign-in check that an API token cannot give |
403 |
BILLING_001 |
The business's trial ended and it has no active subscription |
403 |
BILLING_003 |
The CFO Suite add-on is not active |
404 |
NOT_FOUND_015, BUSINESS_192 |
User is not a member; data export not found |
409 |
CONFLICT_010, CONFLICT_012, CONFLICT_011 |
Mailbox taken (check route), mailbox taken (create or update), duplicate business name for the owner |
409 |
BUSINESS_191 |
Deletion already running, cancel refused |
See Error Handling for the envelope, retry advice and idempotency keys.