Skip to content

Dashboards and AI Reporting

Three route families cover DayZero's saved reporting surfaces:

Family Base path What it holds Add-on required
Dashboards /api/v1/dashboards Saved dashboards of widgets, financial models imported from a workbook, versions, refresh schedules, sharing AI Pro
AI Reporting /api/v1/ai-reporting AI-generated slide decks built from live books data, slide edits, PPTX/PDF export, share links None checked by the API
CFO projection /api/v1/cfo-projection Driver-based income statement projection scenarios CFO Suite

All examples use https://api.ondayzero.com, Authorization: Bearer dz_your_token_here and x-business-id: YOUR_BUSINESS_ID. Money is always integer cents. Success bodies are wrapped as {"success": true, "data": ...} and deletes return {"success": true, "message": "...", "code": "SUCCESS_001"}; errors use the envelope in Error Handling. Every POST here returns 201 (including actions such as refresh and reorder) except a deck export, which streams the file with 200; GET, PUT, PATCH and DELETE return 200. Generating a deck or minting a share link creates a new record every time, so send an Idempotency-Key header on those calls if you may retry them (see Error Handling).

Access and gating

Add-ons. Every /dashboards route, reads included, needs AI Pro; every /cfo-projection route needs CFO Suite. Without it the API answers 403 BILLING_003. An add-on counts as unlocked when any of these is true:

  • the business is in an active trial or is a demo workspace (every add-on is unlocked);
  • the add-on is on the business's paid add-on list;
  • the business is on the grandfathered or partner-program plan and the add-on is in its grandfathered list (the partner-program plan always includes CFO Suite);
  • the business is a firm client and its firm's plan bundles the add-on (CFO Suite is bundled with Firm Professional and Firm Enterprise, AI Pro with Firm Enterprise only).

/ai-reporting routes have no add-on check, so they never return BILLING_003.

Lapsed billing. Write requests (POST, PUT, PATCH, DELETE) on every route here are refused with 403 BILLING_001 when the trial has ended and there is no active subscription; reads keep working. Firm-client, grandfathered, partner-program and demo businesses are not blocked this way.

Permissions. The HTTP method decides the permission the caller's role needs on the business:

Method Permission Held by
GET read every role, including viewers
POST, PUT, PATCH write owner, admin, member
DELETE delete owner and admin. A direct member cannot call any DELETE route (decks, widgets, schedules, scenarios, revoking share links)

A role without the permission gets 403 AUTH_012. A read_only API token can only call GET routes (403 AUTH_048; see Authentication).

Dashboard visibility is stricter than the role. A dashboard is visible to its creator, to every user it is assigned to, to everyone in the business when its visibility is business, and always to owners and admins. Anything else is a 404, never a 403, so IDs cannot be probed. Changing a dashboard (rename, widgets, assignments, versions, schedule, sharing) additionally needs edit rights: the creator or an owner/admin. A viewer without edit rights also gets 404 on those routes. Every dashboard response carries can_edit. AI Reporting decks have no per-user visibility: anyone with access to the business sees all of its decks.

Dashboards

A dashboard is a titled grid of up to 24 widgets. Each widget stores a snapshot of the data it shows (the same payload a Regent chat card renders) and, optionally, a source: the read-only tool and parameters that can regenerate the snapshot on a scheduled refresh.

Widget type Rule
chart snapshot.ui_hint is one of bar_chart, pie_chart, line_chart, spending_chart, forecast_chart, stacked_bar_chart, sparkline_chart, waterfall_chart, scatter_chart
table Any non-chart ui_hint
metric One headline number; the only type an alert can attach to
gauge ui_hint must be gauge_chart
timeline ui_hint must be timeline_chart
text Markdown body up to 8,000 characters; no snapshot needed

Limits: a snapshot is truncated (not rejected) to its first 200 items and 256 KiB, and the whole dashboard must stay under 2 MiB. visibility is private (default), assigned or business.

Create, list, read

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"title": "Weekly cash review", "description": "Cash and AR at a glance", "visibility": "business"}'

title is required (1-200 characters); description is up to 1,000. The 201 response is data.dashboard:

json
{
  "success": true,
  "data": {
    "dashboard": {
      "version": 1,
      "id": "019ab37c-c057-7000-8000-000000000001",
      "title": "Weekly cash review",
      "description": "Cash and AR at a glance",
      "visibility": "business",
      "widgets": [],
      "assigned_user_ids": [],
      "created_by_user_id": "019ab37c-aaaa-7000-8000-000000000001",
      "created_at": "2026-10-14T12:00:00",
      "last_refreshed_at": null,
      "can_edit": true
    }
  }
}
bash
curl "https://api.ondayzero.com/api/v1/dashboards?limit=20" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"

The list returns only dashboards the caller can see, newest first, without widgets: each item has id, title, description, visibility, widget_count, created_by_user_id, created_at, last_refreshed_at and can_edit. Default page size is 20. has_next is true while more rows exist; pass next_cursor as cursor (see Pagination) and stop when it is null. Default limit 20. total is null; sort_by, descending, direction and include_total_count are accepted but ignored.

  • GET /api/v1/dashboards/{dashboard_id} returns the full dashboard (data.dashboard).
  • PATCH /api/v1/dashboards/{dashboard_id} changes title, description or visibility; send only what changes.
  • DELETE /api/v1/dashboards/{dashboard_id} soft-deletes it and its public links stop working (see Share a dashboard).

Widgets

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/{dashboard_id}/widgets" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Cash on hand",
    "widget_type": "metric",
    "snapshot": {"data": {"headline_value": 1250000}, "ui_hint": "metric", "captured_at": "2026-10-14T12:00:00Z"},
    "layout": {"x": 0, "y": 0, "w": 6, "h": 4}
  }'
Field Notes
title Required, 1-200 characters
widget_type Required; one of the types above
snapshot Required except for text: data (object), ui_hint (1-60 characters), captured_at (datetime)
body text widgets only, up to 8,000 characters
layout 12-column grid: x 0-11, y 0-999, w 1-12, h 1-12 (defaults 0, 0, 6, 4). Omit it and the widget is placed below the others, half width (tables full width)
source {"tool_name": "list_invoices", "params": {}, "refreshable": true}. The tool must exist and be a read tool, otherwise 400. Optional transform, up to four extra_sources (only together with a transform) and scope (business default, or firm to aggregate across a firm's client businesses)

A request that would exceed 24 widgets, or whose widget does not match its type's rules, is a 400. The response is the whole dashboard.

Route Effect
PATCH /dashboards/{dashboard_id}/widgets/{widget_id} Change title, layout (replaced as a whole; omitted fields reset to the defaults above) or, on text widgets only, body. Returns the dashboard
DELETE /dashboards/{dashboard_id}/widgets/{widget_id} Remove the widget; returns the dashboard
POST /dashboards/{dashboard_id}/widgets/reorder Body {"widget_ids": [...]} must list every widget exactly once, else 400; returns the dashboard

Assign users

GET /api/v1/dashboards/assignable-users lists up to 200 users of the business (items of id, email, name). PUT /api/v1/dashboards/{dashboard_id}/assignments replaces the assignment list:

bash
curl -X PUT "https://api.ondayzero.com/api/v1/dashboards/{dashboard_id}/assignments" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"user_ids": ["USER_UUID_1", "USER_UUID_2"], "visibility": "assigned"}'

user_ids (up to 200) is the complete new set; send [] to clear it. A user who is not in the business makes the call a 400. If you omit visibility and the dashboard is private, assigning at least one user switches it to assigned. The response is the dashboard with assigned_user_ids.

Versions

Versions are explicit snapshots of title, description, visibility and widgets; editing a dashboard or refreshing it never creates one. At most 25 are kept per dashboard, and saving another deletes the oldest.

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/{dashboard_id}/versions" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"change_summary": "Before Q4 restructure"}'

change_summary is optional (up to 500 characters). The 201 response (data) has id, dashboard_id, version_number, change_summary, widget_count, title, created_by_user_id, created_at, is_current (true for the newest) and the stored spec.

  • GET /dashboards/{dashboard_id}/versions lists them newest first without spec (items plus max_versions).
  • GET /dashboards/{dashboard_id}/versions/{version_id} returns one with its spec.
  • POST /dashboards/{dashboard_id}/versions/{version_id}/restore rolls the dashboard back. The stored spec is re-validated against the current schema first (400 if it predates an incompatible change), and the state being overwritten is saved as a new version before anything changes. The response has dashboard, restored_from_version_number and undo_version_number; restoring that number undoes the restore. Assignments and share links are not part of a version.

Anyone who can see the dashboard can read its versions; saving and restoring need edit rights.

Alerts on metric widgets

POST /api/v1/dashboards/{dashboard_id}/widgets/{widget_id}/alert creates a threshold alert rule on a metric widget. Anyone who can see the dashboard may create one; the rule belongs to the caller.

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/{dashboard_id}/widgets/{widget_id}/alert" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"threshold_value": 500000, "threshold_direction": "below", "channels": ["inbox", "email"], "check_frequency": "daily"}'
Field Notes
threshold_value Required integer (cents for money)
threshold_direction Required: above or below
name 1-255 characters; default is generated from the widget title
value_path Dotted path into the snapshot data, such as totals.cash_on_hand or items.0.balance (numeric segments index lists). Default: the first present of headline_value, value, total, amount
channels Any of inbox, slack, email; default ["inbox"]
slack_channel Up to 100 characters
check_frequency hourly, daily (default) or weekly
cooldown_hours 1-720, default 24
description Up to 1,000 characters

The rule reads the widget's stored snapshot, never the live source, so it is only as fresh as the dashboard. A non-metric widget, a widget with no snapshot data, or a value_path that does not resolve to a plain number (formatted strings such as "$1,234.56" are rejected) is a 400. The 201 response has alert_rule_id, dashboard_id, widget_id, name, rule_type, threshold_value, threshold_direction, value_path, channels, check_frequency, cooldown_hours and is_enabled.

Export

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/{dashboard_id}/export" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"output_format": "xlsx"}'

output_format is pdf (default), xlsx or csv; widget_ids limits the export (empty means all) and include_empty includes widgets with no data. Exports read stored snapshots only, so they are as fresh as the dashboard. Charts are flattened to their underlying table: xlsx gives each widget a worksheet, csv one banner-headed section per widget, pdf the same tables.

The export is asynchronous. The 201 response is task_id, status (queued, or in_progress if the same export is already running), dashboard_id, output_format and message. Poll GET /api/v1/tasks/task/{task_id} until data.status is terminal (SUCCESS, FAILURE, CANCELLED, TERMINATED or TIMED_OUT); on success data.result.download_link is the download URL (call it with curl -L and your normal API headers). Polling and download work exactly as in Exports and Reports.

Financial models

A model is an ordinary dashboard that also carries a model spec (sheets of formula rows over your ledgers), so every dashboard route, including sharing, versions and schedules, works on one. Each step below is synchronous:

  1. Import a reference .xlsx into a draft plan. Nothing is saved as a dashboard yet.
  2. Review the plan and optionally adjust the period axis or assumptions.
  3. Build the draft into a dashboard.
  4. Refresh it from current ledger data, or download it as a live-formula workbook.
bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/models/import" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -F "file=@board-model.xlsx" \
  -F "title=FY2027 operating model"

This is multipart/form-data with a file (.xlsx) and an optional title (default: the file name). Only visible sheets are read. A sheet is used only if it has a header row of dates or period labels (at least three period columns) and no more than 500 rows; longer sheets are skipped as raw data exports. At most 40 sheets are used. A file over 75 MB, a workbook that expands suspiciously, an unreadable workbook, or one with no recoverable model rows is a 400. The 201 response:

Field Meaning
draft_id Handle for the stored draft
plan Review payload: title, periods (grain, start, end, count, labels), assumptions, sheets (with rows), sheet_count, row_count, open_questions, coverage, sample, import, expires_in_seconds
headline One-line summary of the plan
confidence Importer confidence score, 0 to 1
skipped_sheets Sheets skipped as raw data exports
unresolved Formulas that could not be translated and need rebuilding by hand
notes Importer notes

Check plan.open_questions and plan.coverage before building: a data row that matches no ledger in your chart of accounts reads zero.

A draft is kept in a per-business cache for 24 hours (each edit renews the clock) and is deleted once built; plan.expires_in_seconds is the live remainder. A missing or expired draft is 404 on the two draft routes below and 400 on build. The same draft store backs Regent's plan_model chat tool, so a plan proposed in chat can also be built here.

  • GET /api/v1/dashboards/models/drafts/{draft_id} returns draft_id, plan and headline, with every row of every sheet unless you pass ?full=false.
  • PATCH /api/v1/dashboards/models/drafts/{draft_id} edits the draft and returns the re-planned model under the same draft_id:
bash
curl -X PATCH "https://api.ondayzero.com/api/v1/dashboards/models/drafts/{draft_id}" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"grain": "quarter", "start": "2027-01-01", "end": "2027-12-31"}'

Every field is optional: title (1-200 characters), grain (month, quarter, year), start, end (YYYY-MM-DD) and assumptions, a map of keys that already exist in plan.assumptions to numbers (for example {"assumptions": {"your_assumption_key": 0.0055}}), merged into the draft. An unknown assumption key, a non-numeric value, an invalid or too-wide axis (over 250 periods), or an edit that leaves a row referencing a missing assumption is a 400 with the reason. Structural changes are not supported here.

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/models/build" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"draft_id": "DRAFT_ID", "visibility": "business"}'

Build evaluates the draft against live ledger data and returns the new dashboard (201, data.dashboard) whose widgets are the model's sheets; visibility defaults to private.

  • POST /api/v1/dashboards/{dashboard_id}/model/refresh (no body) re-runs the whole model against current ledger data and returns dashboard, sheet_count, period_count and error_count. A dashboard that is not a model is a 400.
  • GET /api/v1/dashboards/{dashboard_id}/model/workbook streams an .xlsx whose computed cells are real Excel formulas (use curl -OJ to keep the filename). Use it rather than the dashboard export when you want live formulas instead of frozen values.

Over MCP. import_model_workbook (pass the s3_key of a .xlsx sent with upload_file; optional title and full_plan) returns a draft_id plus a confidence score, formulas that could not be translated and rows that map to no ledger, and writes nothing. build_model takes that draft_id and a visibility of private, assigned or business and saves the dashboard; refresh_model takes a model's dashboard_id and recalculates every sheet together. Both writes support dry_run and ask for approval. Read both import warnings before building: a row with no ledger mapping builds as zeros, which looks like a working model.

Refresh schedules

A schedule re-runs a dashboard's refreshable widget sources in the background. Each dashboard has at most one. Creating, changing and deleting it needs edit rights.

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/{dashboard_id}/schedule" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"frequency": "weekly", "day_of_week": 0, "hour_utc": 7, "timezone": "America/New_York"}'
Field Notes
frequency hourly, daily (default), weekly or monthly
day_of_week 0 (Monday) to 6; required for weekly
day_of_month 1-31; required for monthly. Months shorter than the value run on their last day
hour_utc 0-23, default 6. Despite the name it is read in timezone, and ignored for hourly (which runs at the top of the next hour)
timezone IANA name, default UTC; an unknown name is a 400
is_enabled Default true

The response is the schedule: id, dashboard_id, the fields above, last_run_at, next_run_at (UTC), last_error, created_by_user_id and created_at. A second POST for the same dashboard is a 400; use PATCH.

  • GET .../schedule returns {"schedule": null} when none exists (not a 404).
  • PATCH .../schedule takes any of the fields. Changing the cadence or re-enabling re-derives next_run_at from now (a long-paused schedule does not fire at once) and clears last_error. A missing day_of_week or day_of_month for the resulting frequency is a 400; 404 if there is no schedule.
  • DELETE .../schedule removes it; the dashboard is untouched. 404 if there is no schedule.

What a run does. A poller checks for due schedules every 5 minutes, so a run starts a few minutes after next_run_at. It executes as the user who created the schedule, using that user's current role: widgets whose tools they can no longer run fail individually, and if they are inactive or lose access to the business the run is abandoned. Widgets with no source, or refreshable: false, keep their stored snapshot. A failed widget keeps its old data and gets a last_refresh_error (up to 500 characters). The run counts as successful if at least one widget refreshed or none failed; otherwise last_error is set on the schedule. next_run_at advances after a failed run too, so a broken widget does not retry in a loop. Model dashboards are refreshed on demand with /model/refresh.

There is no REST route that refreshes a snapshot dashboard's widgets on demand; use a schedule.

Share a dashboard

A share link is a public, no-login, read-only URL. Only users who can edit the dashboard may create or revoke links; anyone who can see it may list them.

bash
curl -X POST "https://api.ondayzero.com/api/v1/dashboards/{dashboard_id}/share" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"expires_in_hours": 168, "slug": "q4-board"}'
json
{
  "success": true,
  "data": {
    "share_id": "019ab37c-c057-7000-8000-0000000000a1",
    "dashboard_id": "019ab37c-c057-7000-8000-000000000001",
    "token": "k3J...",
    "url": "https://app.ondayzero.com/share/dashboards/k3J...",
    "expires_at": "2026-10-21T12:00:00",
    "slug": "q4-board"
  }
}

The token and url are returned only once. Only a SHA-256 hash is stored, so a lost link cannot be recovered; mint a new one. Both body fields are optional. expires_in_hours is an integer from 1 to 8,760 (one year); omit it for a link that never expires and stays valid until revoked or the dashboard is deleted. slug is a lowercase label (letters, digits and hyphens, up to 80 characters) that is never used for lookup. expires_at is UTC. The origin in url is the product app's origin for the environment you call.

  • GET /api/v1/dashboards/{dashboard_id}/shares lists every link, active, expired and revoked, newest first, as items of id, dashboard_id, slug, created_at, expires_at, revoked_at, view_count and last_viewed_at. There is no status field and no token: a link is live when revoked_at is null and expires_at is null or in the future.
  • DELETE /api/v1/dashboards/{dashboard_id}/shares/{share_id} revokes one link and is idempotent. The share must belong to that dashboard, otherwise 404.

What viewers get. GET /api/v1/public/dashboards/{token} needs no Authorization or x-business-id header and no add-on:

bash
curl "https://api.ondayzero.com/api/v1/public/dashboards/k3J..."

It returns data.dashboard (id, title, description, widgets, created_at, last_refreshed_at) and data.cached. Widgets show their stored snapshots; each widget's source and last_refresh_error, the internal _tool_name and _tool_params snapshot keys, visibility, assignments and creator IDs are removed. The same URL without /api/v1/public (/share/dashboards/{token}) is the page a person opens in a browser.

Situation Public result
Unknown token, expired, revoked, or dashboard deleted 404 NOT_FOUND_001, same body for all four
Revoked 404 immediately; the cached copy is dropped on revoke
Expired or dashboard deleted 404, but a copy cached in the last 60 seconds can still be served until it ages out
More than 60 requests per minute from one IP 429 GEN_005

Responses are cached for 60 seconds per token, and view_count and last_viewed_at only update on uncached reads, so treat them as approximate.

AI Reporting decks

A deck is an ordered list of slides (at most 40) built from your live data, each with optional AI commentary. Slide types: cover, exec_summary, pnl_summary, ar_aging, cash_position, inventory_cogs, customer_concentration, asks and custom (your own markdown, up to 8,000 characters).

Generate

bash
curl -X POST "https://api.ondayzero.com/api/v1/ai-reporting/decks" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Q3 board update",
    "period_start": "2026-07-01",
    "period_end": "2026-09-30",
    "slide_types": ["cover", "exec_summary", "pnl_summary", "cash_position", "asks"],
    "title": "Q3 2026 board update"
  }'

Only prompt (1-400 characters) is required; title is up to 200.

  • Slides. Without slide_types, a prompt containing investor, kpi, weekly, snapshot or update (any case) gets the five-slide set (cover, executive summary, P&L summary, cash position, asks); any other prompt gets the eight-slide board set (those plus AR aging, inventory and COGS, customer concentration). The cover is always moved first, asks last, and duplicates are dropped.
  • Period. period_end defaults to today and period_start to the first day of the month two months before period_end. period_start after period_end is a 400. The period is stored with the deck and does not move on refresh. Only the P&L figures follow it; AR aging, cash, inventory and customer slides are current as of the generate or refresh call.
  • Title. Defaults to the prompt, trimmed to 80 characters.
  • Placeholders. A new asks slide starts with two sample entries ("Approve next-quarter hiring plan", "Confirm fundraising posture") that the API cannot edit (PATCH can change only its title, commentary and hidden). Hide it, or use a custom slide for your own asks. A custom slide added without markdown holds placeholder text.

Generation is synchronous: there is no job to poll. It returns once the deck is saved, typically within 5 to 15 seconds for eight slides, so set a generous client timeout. A data section that fails to load leaves its slide with empty data, and a slide whose AI commentary fails comes back with commentary: null; neither fails the request. The 201 response is data.deck (trimmed here):

json
{
  "success": true,
  "data": {
    "deck": {
      "id": "019ab37c-d000-7000-8000-000000000001",
      "business_id": "019ab37c-b000-7000-8000-000000000001",
      "title": "Q3 2026 board update",
      "period_start": "2026-07-01",
      "period_end": "2026-09-30",
      "slides": [
        {
          "id": "019ab37c-d000-7000-8000-0000000000a1",
          "slide_type": "cover",
          "title": "Board Report",
          "data": {"business_name": "Acme Co", "period_label": "Jul – Sep 2026", "generated_at": "2026-10-14T12:00:00", "tagline": "AI-generated board deck"},
          "commentary": null,
          "display_order": 0,
          "hidden": false
        },
        {
          "id": "019ab37c-d000-7000-8000-0000000000a2",
          "slide_type": "cash_position",
          "title": "Cash position",
          "data": {"current_cash_cents": 1250000, "series": [], "burn_rate_cents_per_month": null, "runway_months": null},
          "commentary": {"headline": "Cash is stable", "claims": [{"text": "Cash on hand is $12,500.00.", "citations": [{"source": "cash", "path": "current_cash_cents", "label": "Cash"}]}], "recommendation": null, "trace_id": null},
          "display_order": 1,
          "hidden": false
        }
      ],
      "created_at": "2026-10-14T12:00:00",
      "last_refreshed_at": "2026-10-14T12:00:00",
      "theme": {"accent_color": "#0F766E", "cover_image_url": null}
    }
  }
}

theme comes from your business branding and is null when none is set. Each slide has id, slide_type, title, data (type-specific, money in cents), commentary (headline up to 160 characters, claims of text plus citations, recommendation, trace_id), display_order and hidden.

Read and delete

  • GET /api/v1/ai-reporting/decks lists decks newest first without slides (id, title, period_start, period_end, created_at, slide_count), 20 per page. Paging works the same as in Create, list, read: has_next is true while more decks exist, and next_cursor goes back as cursor.
  • GET /api/v1/ai-reporting/decks/{deck_id} returns the full deck directly in data (not under data.deck). 404 if it does not exist in this business or was deleted.
  • DELETE /api/v1/ai-reporting/decks/{deck_id} soft-deletes the deck; its share links stop resolving.

Refresh

bash
curl -X POST "https://api.ondayzero.com/api/v1/ai-reporting/decks/{deck_id}/refresh" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"regenerate_commentary": false}'

The body is optional. A refresh re-pulls current data into every data-bound slide for the stored period: slide IDs, titles, order, hidden flags and commentary are kept, the cover's generated_at is updated, and custom and asks slides are untouched. Set regenerate_commentary to true to rewrite the commentary of the refreshed slides (this discards manual edits on them). It is synchronous and returns data.deck with a new last_refreshed_at. POST .../slides/{slide_id}/refresh does the same for one slide and returns the slide; for cover, asks and custom slides it is a 400.

Edit slides

Slide routes return the slide directly in data, except reorder, which returns the whole deck.

Route Effect
PATCH /ai-reporting/decks/{deck_id}/slides/{slide_id} Body may set title (up to 200), commentary (replaces the whole block: headline up to 160, up to 8 claims of up to 600 characters with up to 6 citations each, recommendation up to 400), hidden, and markdown (custom slides only, else 400)
POST /ai-reporting/decks/{deck_id}/slides Add a slide: slide_type (required, not cover), optional title, markdown (custom only), position (0-based, clamped to the deck length; default end). Data-bound types are populated from live data with AI commentary
POST /ai-reporting/decks/{deck_id}/slides/{slide_id}/duplicate Copy directly after the original with (copy) appended to the title; no body
POST /ai-reporting/decks/{deck_id}/slides/{slide_id}/toggle-hidden Flip hidden; no body
POST /ai-reporting/decks/{deck_id}/slides/reorder {"slide_ids": [...]} must list every slide exactly once, else 400

Adding or duplicating a slide in a deck that already has 40 is a 400.

Export

bash
curl -X POST "https://api.ondayzero.com/api/v1/ai-reporting/decks/{deck_id}/export?format=pdf" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -o q3-board-update.pdf

format is pptx (default) or pdf; anything else is a 400. The file is built on the server from the stored deck with searchable text and real tables, and hidden slides are left out. The response is the binary file itself (200, Content-Disposition with a name derived from the deck title), not the JSON envelope. A copy is also saved to the business's Documents on a best-effort basis. Sending slide PNGs as repeated multipart slides parts (at most 40, 10 MB each) switches to a legacy screenshot pipeline used by the web app; you do not need it.

Share a deck

bash
curl -X POST "https://api.ondayzero.com/api/v1/ai-reporting/decks/{deck_id}/share" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"expires_in_hours": 720}'

Deck links follow the same rules as dashboard links (expires_in_hours 1-8,760 or omitted for no expiry, optional slug, token and url shown once). The response has share_id, deck_id, token, url (<app origin>/share/decks/{token}), expires_at and slug.

  • GET /api/v1/ai-reporting/decks/{deck_id}/shares lists active, expired and revoked links with view_count and last_viewed_at.
  • DELETE /api/v1/ai-reporting/shares/{share_id} revokes a link (no deck ID in the path; idempotent; 404 if the link is not in this business). The link 404s immediately.
  • Viewers see only visible slides, with internal commentary trace IDs removed.
  • Unlike dashboards, any user with write access can create deck links; revoking needs delete access.

The unauthenticated viewer behind a deck link is internal to the share page and is not part of the documented API.

CFO projection

Scenario-based, driver-driven income statement projections built on your trailing actuals. Requires CFO Suite. Rates are decimals (0.10 is 10 percent) and money is cents. The flow is: read the actuals to get ledger IDs, create a scenario, tune its drivers, compute the projection.

Trailing actuals

bash
curl "https://api.ondayzero.com/api/v1/cfo-projection/actuals?trailing_months=6&hide_zero_rows=true" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"

trailing_months (1-24, default 6) means the last N complete calendar months before the current one. To pin a window instead, send both start_month and end_month as YYYY-MM; one without the other, a reversed range, or more than 24 months is a 400 VALIDATION_004. hide_zero_rows drops all-zero lines. Amounts follow the business's default reporting basis.

The response has months (labels such as Sep 2026) and the line groups revenue, cogs, opex and other. Each line has ledger_id, name and data (cents per month, oldest first). COGS and opex lines add type (var or fix, a default hint: COGS lines are var, opex fix), esc and growth (both currently null); other lines add neg (true for an expense). The ledger_id values are the keys of every driver map below.

Scenarios

bash
curl -X POST "https://api.ondayzero.com/api/v1/cfo-projection/scenarios" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"name": "FY2027 base case", "projection_months": 24, "tax_rate": 0.25,
       "revenue_drivers": {"LEDGER_ID": {"growth": 0.02}},
       "interest_income_monthly": 0, "interest_expense_monthly": 0}'

Every field is optional; drivers you omit are seeded from the trailing actuals. 201 returns the scenario: id, business_id, name, is_active, base_month, projection_months, the driver maps, tax_rate, interest_income_monthly, interest_expense_monthly, created_at and updated_at.

Field Default and meaning
name Default Projection (up to 255 characters)
base_month Date; defaults to the first day of the most recent complete month. See the note under Compute the projection
projection_months 1-60, default 24
revenue_drivers ledger_id to {"growth": 0.02} (compound month-over-month); defaults to 0 growth per revenue line
cost_classifications ledger_id to var or fix; defaults to the actuals hint
variable_drivers ledger_id to {"pct_rev": 0.085} (fraction of projected revenue); seeded from the trailing 3-month average
fixed_drivers ledger_id to {"base": 450000, "esc": 0.03} (cents per month, annual escalation); seeded from the trailing 3-month average with 0 escalation
monthly_overrides ledger_id to a map of month index ("0" is the first projected month) to integer cents; wins over the drivers for that month
tax_rate 0-1, default 0.21
interest_income_monthly, interest_expense_monthly Integer cents, at least 0. If you send neither, both are seeded from the trailing other income and expense, and those amounts are added on top of the other income and expense lines already projected from actuals. Send both (0 is fine) to control them

A new scenario becomes the active one and every other scenario for the business is deactivated; there is no route to reactivate an older one. GET /scenarios/active returns the active scenario or "data": null.

  • GET /api/v1/cfo-projection/scenarios lists scenarios with cursor pagination; GET /scenarios/{scenario_id} fetches one (404 NOT_FOUND_059 for an unknown or deleted ID).
  • POST /api/v1/cfo-projection/scenarios/from-budget/{budget_id} (no body) derives a scenario from a budget: 201, becomes active, named From Budget: <name>, tax rate 0.21, no interest, projection_months equal to the budget's length, and base_month the month before the budget starts. It infers each revenue line's growth from the budget's months and classifies expense lines as variable when they are between 0.1 and 80 percent of revenue, otherwise fixed. A budget with no lines is a 400; an unknown budget is a 404 NOT_FOUND_041.
  • PATCH /api/v1/cfo-projection/scenarios/{scenario_id} updates name, projection_months, tax_rate, the interest fields and the driver maps. Driver maps and monthly_overrides are merged key by key into the stored ones, so send only the lines you change. base_month cannot be changed after creation.
  • DELETE /api/v1/cfo-projection/scenarios/{scenario_id} soft-deletes it.
  • POST /api/v1/cfo-projection/scenarios/{scenario_id}/suggest-tax-rate (no body) asks the AI for two or three effective tax-rate options and returns suggestions of label, rate, reasoning and recommended. It does not change the scenario. It fails with 500 AI_011 if no suggestion can be produced.

Compute the projection

bash
curl "https://api.ondayzero.com/api/v1/cfo-projection/scenarios/{scenario_id}/projection" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"

The computation is synchronous and nothing is stored. How each line is built:

  • Revenue: the last actual month grows by the line's compound growth each month.
  • Cost lines: var lines are pct_rev times projected total revenue; fix lines are base times 1 + esc * month_index / 12. A line with no classification is var in COGS and fix in opex.
  • Overrides: a monthly_overrides amount replaces the computed value for that month index.
  • Other income and expense: each other line from the actuals is held flat at its trailing 3-month average, plus Interest income (scenario) and Interest expense (scenario) lines when the scenario's interest fields are above 0.
  • Tax: pre_tax_income times tax_rate. A stored tax_rate of 0 is computed as 0.21.

The response has scenario_id, months (labels like Oct '26, starting the month after base_month) and:

Field Shape
revenue, cogs, opex Section: label, items (ledger_id, name, data per month, total), total per month, grand_total
other_income, other_expense Lists of items (ledger_id, name, data, total, neg)
gross_profit, operating_income, other_net, pre_tax_income, tax, net_income Integer cents per month
gross_margin_pct, operating_margin_pct, net_margin_pct Percent per month, one decimal (42.5 is 42.5 percent), 0 when revenue is 0

The projection is always computed from the latest 6 complete months of actuals, whatever base_month says; base_month only decides the month labels. Closing more months and recomputing therefore changes the result.

Errors

Status Code Cause
400 GEN_002 Body or query failed validation (including a malformed UUID in the path or an unsupported export format), or a rule above was broken: widget cap, bad cadence or timezone, unknown assumption, expired draft on build, incomplete reorder list, wrong slide type, 40-slide cap, period_start after period_end, scenario from a budget with no lines
400 VALIDATION_004 Invalid actuals window (start_month without end_month, reversed, or over 24 months)
401 AUTH_001 / AUTH_008 Missing or invalid token; missing x-business-id
403 BILLING_003 AI Pro (/dashboards) or CFO Suite (/cfo-projection) is not unlocked
403 BILLING_001 Write attempted while the trial has ended with no subscription
403 AUTH_048 A read_only token called a write route
403 AUTH_012 The caller's role lacks the permission the method needs (for example a direct member calling DELETE)
404 NOT_FOUND_001 Unknown ID, deleted item, a dashboard you cannot see or may not edit, a model draft that expired, a share link on another dashboard or business
404 NOT_FOUND_059 / NOT_FOUND_041 Unknown CFO projection scenario / unknown budget
422 GEN_002 Malformed cursor query parameter
429 GEN_005 Public dashboard viewer rate limit (60 per minute per IP)
500 AI_011 suggest-tax-rate could not produce suggestions