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
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:
{
"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
}
}
}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}changestitle,descriptionorvisibility; send only what changes.DELETE /api/v1/dashboards/{dashboard_id}soft-deletes it and its public links stop working (see Share a dashboard).
Widgets
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:
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.
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}/versionslists them newest first withoutspec(itemsplusmax_versions).GET /dashboards/{dashboard_id}/versions/{version_id}returns one with itsspec.POST /dashboards/{dashboard_id}/versions/{version_id}/restorerolls the dashboard back. The stored spec is re-validated against the current schema first (400if it predates an incompatible change), and the state being overwritten is saved as a new version before anything changes. The response hasdashboard,restored_from_version_numberandundo_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.
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
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:
- Import a reference
.xlsxinto a draft plan. Nothing is saved as a dashboard yet. - Review the plan and optionally adjust the period axis or assumptions.
- Build the draft into a dashboard.
- Refresh it from current ledger data, or download it as a live-formula workbook.
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}returnsdraft_id,planandheadline, 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 samedraft_id:
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.
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 returnsdashboard,sheet_count,period_countanderror_count. A dashboard that is not a model is a400.GET /api/v1/dashboards/{dashboard_id}/model/workbookstreams an.xlsxwhose computed cells are real Excel formulas (usecurl -OJto 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.
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 .../schedulereturns{"schedule": null}when none exists (not a404).PATCH .../scheduletakes any of the fields. Changing the cadence or re-enabling re-derivesnext_run_atfrom now (a long-paused schedule does not fire at once) and clearslast_error. A missingday_of_weekorday_of_monthfor the resulting frequency is a400;404if there is no schedule.DELETE .../scheduleremoves it; the dashboard is untouched.404if 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.
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"}'{
"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}/shareslists every link, active, expired and revoked, newest first, asitemsofid,dashboard_id,slug,created_at,expires_at,revoked_at,view_countandlast_viewed_at. There is no status field and no token: a link is live whenrevoked_atisnullandexpires_atisnullor 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, otherwise404.
What viewers get. GET /api/v1/public/dashboards/{token} needs no Authorization or x-business-id header and no add-on:
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
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 containinginvestor,kpi,weekly,snapshotorupdate(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_enddefaults to today andperiod_startto the first day of the month two months beforeperiod_end.period_startafterperiod_endis a400. 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
asksslide starts with two sample entries ("Approve next-quarter hiring plan", "Confirm fundraising posture") that the API cannot edit (PATCHcan change only its title, commentary andhidden). Hide it, or use acustomslide for your own asks. Acustomslide added withoutmarkdownholds 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):
{
"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/deckslists 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_nextis true while more decks exist, andnext_cursorgoes back ascursor.GET /api/v1/ai-reporting/decks/{deck_id}returns the full deck directly indata(not underdata.deck).404if 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
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
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.pdfformat 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
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}/shareslists active, expired and revoked links withview_countandlast_viewed_at.DELETE /api/v1/ai-reporting/shares/{share_id}revokes a link (no deck ID in the path; idempotent;404if the link is not in this business). The link404s 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
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
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/scenarioslists scenarios with cursor pagination;GET /scenarios/{scenario_id}fetches one (404NOT_FOUND_059for 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, namedFrom Budget: <name>, tax rate 0.21, no interest,projection_monthsequal to the budget's length, andbase_monththe 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 a400; an unknown budget is a404NOT_FOUND_041.PATCH /api/v1/cfo-projection/scenarios/{scenario_id}updatesname,projection_months,tax_rate, the interest fields and the driver maps. Driver maps andmonthly_overridesare merged key by key into the stored ones, so send only the lines you change.base_monthcannot 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 returnssuggestionsoflabel,rate,reasoningandrecommended. It does not change the scenario. It fails with500AI_011if no suggestion can be produced.
Compute the projection
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
growtheach month. - Cost lines:
varlines arepct_revtimes projected total revenue;fixlines arebasetimes1 + esc * month_index / 12. A line with no classification isvarin COGS andfixin opex. - Overrides: a
monthly_overridesamount replaces the computed value for that month index. - Other income and expense: each
otherline from the actuals is held flat at its trailing 3-month average, plusInterest income (scenario)andInterest expense (scenario)lines when the scenario's interest fields are above 0. - Tax:
pre_tax_incometimestax_rate. A storedtax_rateof0is computed as0.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 |