Skip to content

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

bash
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).

bash
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:

bash
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).

bash
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.

bash
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.

bash
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
bash
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

bash
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

bash
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.

bash
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

bash
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:

bash
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.

bash
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.

bash
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).

bash
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.

bash
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/buckets lists default and custom buckets (key, label, color, classification, sort_order, is_active, is_default, can_delete).
  • POST .../cfo-portal/buckets creates a custom bucket: key (lowercase slug, up to 40), label (up to 100), color (green, orange, indigo, accent, gray; default gray), classification (gross_revenue, revenue_adjustments, operating_revenue, cogs, cost_of_sales, operating_expense, other_income, other_expense; default operating_expense) and sort_order. A key that matches a built-in bucket is 400 VALIDATION_265; a duplicate is 409 CONFLICT_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-ins revenue, cogs, software_tech, marketing and ga are locked (400 VALIDATION_265). An unknown custom bucket is 404 NOT_FOUND_070.
bash
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.

bash
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

bash
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

bash
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

bash
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_id needs read on that business.
  • firm_id needs a firm staff role (owner, admin or member). A firm member sees the roster with email set to a placeholder and names removed; firm owner and admin see 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.

bash
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.