Anomalies, Alerts and Proposed Changes
These route families cover what DayZero raises for a person to look at, and what a person can tell it in return:
| Family | What it is | Base path |
|---|---|---|
| Anomalies | Findings from the duplicate and outlier scan (duplicate payments, duplicate vendors, ...) | /api/v1/anomalies |
| Proposed changes | Writes an API client or agent previewed, queued for a person to approve, singly or in a round | /api/v1/pending-changes, /api/v1/pending-change-rounds |
| Decisions | A dated log of rulings the categorizer follows | /api/v1/decisions |
| Notifications | The feed behind the bell, with a Seen, Snoozed and Done workflow | /api/v1/notifications |
| Attention items | A merged feed of notifications and live alerts | /api/v1/attention |
| Notification preferences | Per-topic delivery channels | /api/v1/preferences/notifications |
| Daily brief | The tiles behind the Books home screen | /api/v1/books/daily-brief |
All paths are relative to https://api.ondayzero.com. Every successful response is wrapped as {"success": true, "data": ...}; the examples below show the contents of data. Errors use the envelope described in Error Handling, and list endpoints follow Pagination except where noted.
For the screens these routes back, see the Anomalies, Inbox, Notifications and Daily Brief articles in the app's Knowledge Base. Every decision on a proposed change or round is also pushed as a webhook event (pending_change.decided, approval_round.decided); see Webhooks.
Permissions and gating
A route's required permission follows its HTTP method: GET needs read, POST, PUT and PATCH need write, DELETE needs delete. A token with the read_only scope can only call GET. A missing permission is a 403.
| Gate | Applies to | Failure |
|---|---|---|
| AI Pro add-on | Every /anomalies route, reads included. Unlocked during a trial |
403, BILLING_003 |
| Active billing | Every write (POST, PUT, PATCH, DELETE) on routes that take x-business-id: anomalies, pending changes, decisions, notifications. Reads still work |
403, BILLING_001 (trial ended) or BILLING_002 (no subscription) |
| Signed-in person | Approving or rejecting a proposed change or round, and changing approval settings. An API token is refused | 403, AUTH_057 |
/attention and /preferences/notifications do not take a business: they authenticate the user only, so the active-billing gate does not apply to them.
Anomalies
The anomaly scan looks for things that are probably mistakes. It is not run by your request: a scheduled job scans active businesses that have transactions once a day (05:00 UTC) and again after a bank sync, and stores the result. The list and detail routes read that stored scan. POST /anomalies/rescan asks for a fresh one.
Finding types
anomaly_type |
Catches | Severity | Has recommended_action |
|---|---|---|---|
duplicate_payment |
Same counterparty and amount paid more than once close together (recurring payments excluded) | high from $1,000, else medium |
void_duplicate_transactions |
unusual_amount |
A payment far outside the usual range for a counterparty | medium |
no |
rapid_succession |
Several payments to one counterparty within an hour | medium |
no |
duplicate_invoice_payment |
An invoice with more than one payment record | high |
void_duplicate_invoice_payments (when two or more payments) |
duplicate_bill_payment |
A bill with more than one payment record | high |
void_duplicate_bill_payments (when two or more payments) |
duplicate_vendor |
Active vendors that share an email or have a near-identical name | medium |
merge_vendors |
The transaction checks cover a 30-day lookback. The invoice, bill and vendor checks ignore the lookback. Severity is low, medium or high. A scan can also carry other types (for example amount_outlier, threshold_pattern, miscategorization); treat anomaly_type as an open string.
Each finding is identified by its fingerprint, a stable SHA-256 hash of the type and the records involved. A dismissal is stored against the fingerprint, so the same finding stays dismissed on later scans.
List anomalies
curl "https://api.ondayzero.com/api/v1/anomalies?severity=high&show_dismissed=false" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"| Query parameter | Description |
|---|---|
severity |
low, medium or high. Any other value returns an empty list |
anomaly_type |
One type from the table above |
show_dismissed |
true to include dismissed findings. Default false |
start_date, end_date |
Inclusive YYYY-MM-DD window, matched against the dates carried in each finding. A finding with no date information is always kept |
The list is not paginated; it returns the whole stored scan, highest severity first.
{
"anomalies": [
{
"anomaly_type": "duplicate_vendor",
"severity": "medium",
"description": "2 vendors look like duplicates: 'Acme Co', 'ACME Co.'. Match reason: fuzzy name match.",
"transaction_ids": [],
"entity_ids": ["VENDOR_UUID_1", "VENDOR_UUID_2"],
"metadata": {"match_reason": "fuzzy_name", "match_key": "acme", "vendor_count": 2},
"fingerprint": "9f2c...e1",
"recommended_action": {
"action": "merge_vendors",
"label": "Merge vendors",
"params": {"keep_vendor_id": "VENDOR_UUID_1", "merge_vendor_ids": ["VENDOR_UUID_2"]}
},
"evidence": [
{"vendor_id": "VENDOR_UUID_1", "name": "Acme Co", "email": "ap@acme.example"},
{"vendor_id": "VENDOR_UUID_2", "name": "ACME Co.", "email": null}
],
"first_detected_at": "2026-10-09T05:00:12+00:00",
"dismissed": false,
"dismissal": null
}
],
"summary": {"count": 1, "by_type": {"duplicate_vendor": 1}, "by_severity": {"medium": 1}, "dismissed_count": 0},
"lookback_days": 30,
"last_scanned": "2026-10-10T05:00:31+00:00"
}summary counts active (not dismissed) findings after your filters, plus dismissed_count. metadata and evidence are shaped per type. If no scan exists yet, anomalies is empty and last_scanned is null. Dismissed findings carry dismissal: {id, reason, notes, dismissed_by, dismissed_at}.
GET /api/v1/anomalies/{fingerprint} returns one finding in the same shape (404, NOT_FOUND_088, when the fingerprint is not in the latest scan).
Rescan
curl -X POST "https://api.ondayzero.com/api/v1/anomalies/rescan" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Returns immediately with {"status": "started", "workflow_id": "...", "business_id": "..."}. The scan runs in the background; fetch GET /anomalies again shortly after and compare last_scanned. A rescan uses a 30-day lookback and is limited to one per business every 5 minutes (429 otherwise). The rescan creates or grows the anomaly notification only when it finds fingerprints that were not in the previous scan.
Dismiss and restore
curl -X POST "https://api.ondayzero.com/api/v1/anomalies/dismiss" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"fingerprint": "9f2c...e1",
"anomaly_type": "duplicate_vendor",
"reason": "false_positive",
"notes": "Two real entities, different tax IDs"
}'| Field | Required | Description |
|---|---|---|
fingerprint |
yes | From the finding |
anomaly_type |
yes | The finding's type |
reason |
yes | expected, resolved, false_positive or other (anything else is a 400, VALIDATION_214) |
description |
no | Snapshot of the finding's description |
notes |
no | Free text |
The response is {id, fingerprint, anomaly_type, reason, notes}. Dismissing is an upsert: dismissing a fingerprint again overwrites the earlier reason and notes. The route does not check that the fingerprint exists in the current scan.
POST /api/v1/anomalies/restore?fingerprint=... removes a dismissal (no body) and returns {"status": "restored"}. It is a 404 (NOT_FOUND_056) when that fingerprint was not dismissed.
Bulk dismiss
All three take a reason (same values) and optional notes, skip findings that are already dismissed, and return {"dismissed_count": N}.
| Route | Extra body fields | Dismisses |
|---|---|---|
POST /api/v1/anomalies/dismiss-type |
anomaly_type |
Every active finding of that type |
POST /api/v1/anomalies/dismiss-all |
none | Every active finding in the latest scan |
POST /api/v1/anomalies/dismiss-by-period |
start_date, end_date (YYYY-MM-DD) |
Every active finding whose dates overlap the window. Findings with no date match every window and are dismissed too |
curl -X POST "https://api.ondayzero.com/api/v1/anomalies/dismiss-by-period" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"start_date": "2026-01-01", "end_date": "2026-01-31", "reason": "expected", "notes": "Reviewed offline"}'Resolve (combine vendors)
POST /api/v1/anomalies/{fingerprint}/resolve runs the finding's recommended_action and records a dismissal with reason resolved in the same call. The body is optional.
curl -X POST "https://api.ondayzero.com/api/v1/anomalies/9f2c...e1/resolve" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"keep_vendor_id": "VENDOR_UUID_1", "notes": "Same company"}'What the call really does depends on the action:
recommended_action.action |
Effect |
|---|---|
merge_vendors |
Performs the merge. Bills, credits, contracts, purchase orders, recurring templates, fixed assets and other references move to the surviving vendor, duplicate registry entries are folded together, and the merged vendors are soft-deleted. keep_vendor_id overrides which vendor survives (default: the oldest, as recommended in the finding) |
void_duplicate_transactions, void_duplicate_invoice_payments, void_duplicate_bill_payments |
Does not void anything. It dismisses the finding as resolved and stores the intended action in the dismissal notes, so the audit trail shows the intent. The reversal must be done on the transaction, invoice or bill itself |
{
"status": "resolved",
"action": "merge_vendors",
"merge_summary": {
"status": "merged",
"keep_vendor_id": "VENDOR_UUID_1",
"merge_vendor_ids": ["VENDOR_UUID_2"],
"merged_count": 1,
"references_moved": {"bills": 3},
"total_references_moved": 3,
"registries_merged": 1
},
"dismissal": {"id": "...", "fingerprint": "9f2c...e1", "anomaly_type": "duplicate_vendor", "reason": "resolved"}
}merge_summary is null for the void actions, and the exact keys inside references_moved depend on what the merged vendors were linked to. A finding with no recommended action (for example unusual_amount) is a 400 (VALIDATION_320); dismiss it instead. The merge cannot be undone through this route, and a dismissal made here can still be restored, which does not reverse the merge.
What is automatic
- Daily and post-sync scans; you never have to call a scan route to keep findings current.
- One living notification per business with topic
anomaly. When a scan finds new fingerprints it creates the notification or adds to it, and brings it back to unread. A notification you dismissed that same day stays dismissed, and a snoozed one keeps its snooze. See Notifications. - Dismissals suppress a finding on every later scan. Dismissing the notification does not dismiss the findings; the app's "Dismiss all anomalies" action calls
dismiss-all.
Proposed changes
A proposed change is a write that someone previewed and queued instead of applying. It is how an agent or script holding an API token asks a person to approve a change. Nothing touches the books until a signed-in person approves it. The app shows these under Proposed changes.
Status flow
pending -> approved -> applied
\-> failed
pending -> rejected
pending -> expired (after 7 days)| Status | Meaning |
|---|---|
pending |
Waiting for a decision |
approved |
A person approved it and the apply is running. This is brief; a finished change is applied or failed |
applied |
The write ran. A bulk write that saved some rows before an error is also recorded as applied, with error set |
failed |
The write was refused or errored and nothing was saved. error holds the reason; stale_plan is true when the books changed since the preview (preview and propose again) |
rejected |
A person closed it without applying |
expired |
Nobody decided within 7 days. Expired is not approved. Expiry is applied when the list, a single change or a decision is read, and by an hourly sweep |
Only pending changes can be decided. Approving or rejecting a change in any other status is a 409: BUSINESS_145 (already decided) or BUSINESS_146 (expired).
Propose a change
curl -X POST "https://api.ondayzero.com/api/v1/pending-changes" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"tool_name": "create_journal_entry",
"arguments": {
"entry_date": "2026-09-30",
"description": "September interest accrual",
"line_entries": [
{"ledger_id": "INTEREST_EXPENSE_LEDGER_UUID", "amount_cents": 125000},
{"ledger_id": "ACCRUED_INTEREST_LEDGER_UUID", "amount_cents": -125000}
]
},
"preview": {"rows": [{"id": "new", "field": "journal_entry", "from": null, "to": "September interest accrual", "amount_cents": 125000}]},
"summary": "Accrue September interest ($1,250.00)"
}'| Field | Required | Description |
|---|---|---|
tool_name |
yes | The write tool to run on approval (up to 128 characters). It is a DayZero assistant/MCP tool name such as create_journal_entry or bulk_edit_transactions, not a REST path |
arguments |
yes | The exact arguments the tool takes when applied, using the tool's own parameter names and units, which can differ from the REST route for the same action. business_id, dry_run, preview_only and response_format are set by the server and removed. Up to 1,000,000 bytes |
preview |
yes | What the dry run returned (object or list; up to 2,000,000 bytes). Rows shaped {id, field, from, to, amount_cents} render as a before/after table |
plan_hash |
no | The plan_hash the preview returned (up to 64 characters). Tools that take one refuse to apply if the books have since changed. It can be sent here or inside arguments, but both must agree (400, BUSINESS_149) |
summary |
yes | One line a person reads first (up to 300 characters) |
round_id |
no | An open approval round of this business to file the change in (404 BUSINESS_220 if unknown, 409 BUSINESS_221 if the round is no longer open) |
The response is 201 with the change (see Response fields). The proposal is validated now, as an apply would be, so a change that could never run is refused up front:
| Status | Code | Reason |
|---|---|---|
400 |
BUSINESS_141 |
Unknown tool name |
400 |
BUSINESS_142 |
The tool only reads data; call it directly |
400 |
BUSINESS_143 |
The tool cannot be applied from the inbox (including create_pending_change itself and tools that exist only on the MCP server) |
400 |
BUSINESS_144 |
arguments contain names the tool does not take, or fail its validation (message says which) |
400 |
BUSINESS_148 |
arguments or preview over the size limit |
403 |
BUSINESS_147 |
The caller lacks write access, or delete access for a destructive tool (names starting delete_, void_, cancel_, remove_ and their bulk_ forms, or any tool the registry marks as deleting) |
If a pending change with the same tool_name and plan_hash already exists, no second row is created: the existing change is returned with duplicate: true (still 201). Deduplication needs a plan_hash.
On creation the business's owner and admins receive an action-required notification (topic pending_change, severity warning, button Review), unless the change joined a round, which is announced once when the round is submitted. It is resolved automatically when the change is approved, rejected or expires.
Tiers
Every change is stamped with a blast-radius tier when it is proposed, from what it would do rather than from how sure the agent is. The tier decides how it can be approved:
tier |
tier_label |
Which changes | How it is approved |
|---|---|---|---|
1 |
Rule | Rule-shaped changes under materiality: bank rules, counterparty defaults and naming rules, recorded decisions | In a round, or on its own |
2 |
Review | Everything else | In a round ("all except"), or on its own |
3 |
Approve individually | Reference data and irreversible or sensitive actions: merges, creating, deleting or archiving ledgers, opening balances, equity, payroll, tax and loan accounts, period close, lock, reopen and sign-off, reversing or voiding a posted entry, anything dated in a closed period, anything that deletes, voids or cancels, and any change whose impact reaches the business's materiality | Only one at a time with POST /pending-changes/{change_id}/approve. A round approval never applies it |
tier_reasons says why a change got its tier, impact_cents is the largest absolute amount it touches, rows_affected the number of rows, and reversible whether it can be undone. Materiality is $5,000 unless the business sets its own (see Approval settings).
List and get
curl "https://api.ondayzero.com/api/v1/pending-changes?status=pending,failed&limit=20" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"status takes one status or a comma-separated list (an unknown value is a 400). round_id limits the list to one round's changes, or to changes in no round with round_id=none. Results are newest first. limit is 1 to 100 (default 20); cursor is the next_cursor from the previous page. The response is {items, next_cursor, has_next, pending_count}, where pending_count is every change still waiting in the business, regardless of your filter.
GET /api/v1/pending-changes/{change_id} returns one change. List items leave arguments, preview and result as null and cap diff at 50 rows; the single-change response includes them and up to 500 diff rows (diff_total is the number before truncation). An unknown id is a 404 (BUSINESS_140).
Approve or reject
Both routes need a signed-in person; an API token gets 403 (AUTH_057). Both take a body {"note": "..."} (up to 2,000 characters) kept with the decision. The note is optional on approve; a rejection needs one of at least 3 characters (400 VALIDATION_860 otherwise), because the agent that proposed the change learns from it.
curl -X POST "https://api.ondayzero.com/api/v1/pending-changes/CHANGE_ID/approve" \
-H "Authorization: Bearer YOUR_SESSION_TOKEN" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"note": "Matches the bank statement"}'Approval runs the tool as the approver, with the approver's role and permissions, after re-checking that the tool is still allowed and that the approver has write access (and delete access for a destructive tool). A 403 leaves the change pending. Once the apply starts the change is decided and ends applied or failed. The apply runs in a savepoint, so a failure leaves nothing behind except the change's own status and error. Dry-run flags are forced off, and the stored plan_hash is passed to tools that take one, so a stale plan is refused rather than applied.
POST .../reject with {"note": "Wrong period"} sets rejected and applies nothing. Both decisions are written to the approval audit log along with the note, and each is pushed as a pending_change.decided event.
A request to approve or reject returns the updated change in the same shape. Both decision routes are plain POSTs, so they need write permission and an active subscription.
Response fields
| Field | Description |
|---|---|
id, business_id |
Identifiers |
tool_name, summary |
What was proposed |
status |
See the table above |
destructive |
The tool deletes, voids or cancels something |
tier, tier_label, tier_reasons, impact_cents, rows_affected, reversible |
The tier and what it was based on |
round_id |
The approval round the change is filed in, or null |
plan_hash, plan_hash_enforced |
The hash, and whether the apply will re-check it (true only if the tool accepts one) |
proposed_by_user_id, proposed_by_name, proposed_via |
Who proposed it; proposed_via is mcp, api, chat or workflow. This route records api |
decided_by_user_id, decided_by_name, decided_at, decision_note |
The decision, once made |
expires_at, created_at, updated_at |
Timestamps (expiry is 7 days after creation) |
diff, diff_total |
Rows {id, label, field, before, after, amount_cents}. When the preview has no rows, they are read from arguments |
arguments, preview, result |
Detail only. result is the tool's output after an apply (replaced with {"truncated": true, "bytes": N} above 200,000 bytes) |
error, stale_plan |
Why an apply failed |
duplicate |
Create only: an identical pending proposal already existed |
Approval rounds
A round batches many proposed changes so a person approves "all except" instead of one by one, typically once at month end. Rounds follow open → submitted → decided; an open or submitted round becomes expired seven days after it was opened or submitted.
- Open a round:
curl -X POST "https://api.ondayzero.com/api/v1/pending-change-rounds" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"title": "Sep 2026 intake and categorization", "period_start": "2026-09-01"}'title is required (up to 200 characters); period_start is optional and must be the first day of a month (400 VALIDATION_863).
- File changes with
POST /pending-changesand the round'sidasround_id. - Submit with
POST /pending-change-rounds/{round_id}/submit(no body). The round stops taking changes and its approvers are told once. It is409if the round is not open (BUSINESS_221), has lapsed (BUSINESS_224) or has no waiting change (BUSINESS_223). A round that stays undecided for 48 hours sends anapproval_round_remindernotification to the owner and admins and to the business's deputy approver. - A signed-in person decides it:
curl -X POST "https://api.ondayzero.com/api/v1/pending-change-rounds/ROUND_ID/approve" \
-H "Authorization: Bearer YOUR_SESSION_TOKEN" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"except_ids": ["CHANGE_ID_TO_HOLD_BACK"], "note": "Hold the Acme merge"}'except_ids (up to 500) names changes to leave waiting; an id that is not in the round is a 400 (VALIDATION_861). Every other waiting change is approved and applied on its own, as the approver, with the same checks as a single approval; one that fails is recorded and the rest carry on. Tier 3 changes are never applied by a round approval and stay waiting. The response is {round, outcomes, applied, failed, rejected, left_pending}, where each outcome has id, summary, tier, outcome (applied, failed, rejected, left_pending or not_decided) and detail. POST .../reject with {"reason": "..."} (3 to 2,000 characters) rejects every change still waiting. Deciding a round that is not submitted is 409 (BUSINESS_222).
GET /pending-change-rounds lists rounds newest first (status, one or a comma-separated list of open, submitted, decided, expired; cursor; limit 1 to 100, default 20) as {items, next_cursor, has_next}. GET /pending-change-rounds/{round_id} returns the round with its changes, tier 3 first and then by impact. A round carries id, title, period_start, status, proposed_by_name, proposed_via, created_at, submitted_at, expires_at, decided_at, decided_by_name, decision_note and counts (total, pending, approved, applied, failed, rejected, expired, by_tier, pending_by_tier, total_impact_cents, pending_impact_cents).
Approval settings and statistics
GET /api/v1/pending-changes/settings (owner or admin) returns approval_materiality_cents (null when unset), effective_materiality_cents (what the tiers use), approval_deputy_user_id and approval_deputy_name. PATCH the same path changes approval_materiality_cents (0 or more; null resets to the $5,000 default) and approval_deputy_user_id (someone who can approve changes on this business; null clears it). Send only what changes. Changing settings needs a signed-in owner or admin: an API token gets 403 AUTH_057, and an invalid value is 400 VALIDATION_862.
GET /api/v1/pending-changes/stats?days=90 (days 1 to 730) shows how proposals were decided: per tool proposed, approved, rejected, expired, pending, approval_rate and median_minutes_to_decision, per decided round the batch size and minutes_to_decision, and median_round_size and median_round_minutes. An approval rate near 1.0 on large rounds decided in seconds can mean rounds are being rubber-stamped.
Decisions
The decision log records dated rulings about how a business's books are kept. Unlike a counterparty default, a decision has an effective window and keeps its history: changing a ruling ends the old row and starts a new one. The categorization engine reads counterparty_ledger rulings first when it categorizes a counterparty.
kind |
subject |
ruling |
|---|---|---|
counterparty_ledger |
Counterparty name | A ledger id (must be a ledger of this business) |
counterparty_name |
Raw counterparty name | The name to show |
policy |
Policy key, lower-cased with underscores (ap_method, accounting_basis) |
The value. ap_method and accounting_basis accept accrual or cash; other keys take any text |
note |
A label | Free text |
Record a decision
curl -X POST "https://api.ondayzero.com/api/v1/decisions" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"kind": "counterparty_ledger",
"subject": "Amazon Web Services",
"ruling": "SOFTWARE_LEDGER_UUID",
"effective_from": "2026-10-01",
"notes": "Hosting is cost of service"
}'| Field | Required | Description |
|---|---|---|
kind |
yes | One of the four kinds |
subject, ruling |
yes | 1 to 500 characters each |
ruling_label |
no | Display text; for a ledger ruling the ledger's name is filled in |
effective_from |
no | First day it applies. Defaults to today |
effective_to |
no | First day it no longer applies (exclusive). Omit for no end |
notes |
no | Up to 2,000 characters |
source |
no | owner (default), agent or app |
A ruling already in force for the same kind and subject is ended on the new effective_from and linked to the new row through its superseded_by_id. Errors: 400 VALIDATION_612 (bad dates, or the window overlaps a later ruling) and VALIDATION_613 (empty or invalid ruling, such as a non-UUID ledger or an unsupported policy value); 409 VALIDATION_611 when a decision for that kind and subject already starts on the same date (update it instead).
The response is the decision: id, kind, subject, ruling, ruling_label, effective_from, effective_to, notes, decided_by_user_id, source, superseded_by_id, active (it applies today), created_at, updated_at.
List, update, end
GET /api/v1/decisions filters by kind, subject (counterparty name or policy key) and active_on (YYYY-MM-DD, only rulings in force that day). subject is a case-insensitive exact match. It is cursor-paginated (limit 1 to 200, default 50) and returns {items, total, has_next, next_cursor}, ordered by effective_from, newest first.
PATCH /api/v1/decisions/{decision_id} accepts ruling, ruling_label, effective_to (send null to clear the end) and notes.
- Changing
notes, the label or the end date edits the row in place. - Changing
rulingon a decision that has already started creates a new row effective today, ends the old one today, and returns the new row. - A decision that was already superseded or has ended cannot be changed (
400,VALIDATION_614); change the current one.
DELETE /api/v1/decisions/{decision_id} needs delete permission. It does not remove the row: it ends the ruling today (or at its start date if that is later) and returns it with its new effective_to. An unknown or malformed id is a 404 (VALIDATION_610).
Notifications
Notifications are created by the system (anomaly scans, approval requests, bank connection problems, proposed changes, month-end and more); the API only reads and acts on them. Each is addressed to a user, a business or an advisory firm.
Status
| Status | Meaning |
|---|---|
new |
Unread |
seen |
Read. Opening a notification with GET moves new to seen automatically |
snoozed |
Hidden until snoozed_until. Once that time passes the notification counts as open again and is reported as seen |
resolved |
Handled. Final |
dismissed |
Not an issue. Final |
Allowed moves: new to seen, snoozed, resolved or dismissed; seen to snoozed, resolved or dismissed; snoozed to seen only. Resolved and dismissed rows cannot move. An illegal move is a 400. Asking for the status a row already has succeeds without change, so retries are safe. Other fields: severity is info, warning or urgent; source is alert, task, system, digest, announcement or message; pinned keeps a row at the top.
List
curl "https://api.ondayzero.com/api/v1/notifications?scope=business&scope_id=YOUR_BUSINESS_ID&topic=anomaly&topic=ap_approval&limit=50" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"| Query parameter | Description |
|---|---|
scope |
me (default, everything addressed to you), business or firm |
scope_id |
Business or firm id. Required for business and firm (400 without it). A business or firm you do not belong to is a 403 |
status, topic, severity |
Repeatable filters (?topic=anomaly&topic=billing). Without status you get open rows: new and seen, plus snoozes that have elapsed |
include_terminal |
true to include resolved and dismissed rows |
include_snoozed |
true to include snoozed rows whose timer has not elapsed |
include_live |
true (default) merges live alerts, see below |
live_business_id |
With scope=me, the business whose live alerts to merge |
limit, cursor |
limit 1 to 200, default 50. The response is {items, next_cursor, has_next} |
Topics: billing, ap_approval, anomaly, plaid, xero, plaid_sync, xero_sync, quickbooks, quickbooks_sync, subscription, month_end, intercompany, ledger, notes, email_ingest, reconciliation, credit, pending_change, approval_round_reminder, background_job_finished, agent_close_overdue, manual.
Each item has id, recipient_kind (user, business or firm), recipient_id, scope_business_id, scope_firm_id, source, topic, severity, status, snoozed_until, pinned, title, body, action_label, action_url (an in-app link), has_action, attachments, entity_type, entity_id, channels, is_virtual, expires_at, metadata, created_at, updated_at and status_changed_at. Expired rows (expires_at in the past) are left out of lists and counts.
Live alerts are computed from current data on every read: low balance, overdue and upcoming bills and invoices, uncategorized transactions, bank connection problems and high-severity anomalies. They are merged in as virtual rows (is_virtual: true, id virtual:<alert>:<business_id>) on the first page only, when the business is one you belong to (via scope=business or live_business_id) and include_terminal is off. They can be dismissed, resolved and pinned. Snoozing one is a 400, and marking one seen has no effect. Dismissing one only hides it for you, and it can return if the underlying condition does.
Counts
GET /api/v1/notifications/count returns {"count": N}: unread (new) rows by default; unread_only=false counts open rows (new plus seen plus elapsed snoozes). GET /api/v1/notifications/topic-counts returns {"counts": {"anomaly": 3, ...}}, open rows per topic. Both accept scope_business_id to limit to one business (business_id is a deprecated alias); a business you cannot access returns zero counts rather than an error.
Act on one notification
| Route | Effect |
|---|---|
GET /notifications/{id} |
Fetch one; moves new to seen |
POST /notifications/{id}/seen |
Mark seen |
POST /notifications/{id}/resolve |
Mark resolved |
POST /notifications/{id}/dismiss |
Dismiss |
POST /notifications/{id}/snooze |
Snooze. Body {"until": "2026-10-12T09:00:00Z"} (ISO 8601). Send a future time: the API does not reject a past one, and a snooze whose time has passed reads as open again. A snoozed row cannot be snoozed again; mark it seen first |
PUT /notifications/{id}/pin |
Body {"pinned": true} or false |
curl -X POST "https://api.ondayzero.com/api/v1/notifications/NOTIFICATION_ID/snooze" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"until": "2026-10-12T09:00:00Z"}'Each returns the updated notification. A malformed or unknown id is a 404.
Bulk
| Route | Body | Effect |
|---|---|---|
POST /notifications/bulk/seen |
{"notification_ids": [...]} (1 to 200 ids) |
Mark those seen |
POST /notifications/bulk/dismiss |
{"notification_ids": [...]} |
Dismiss those |
POST /notifications/mark-all-seen |
{"scope", "scope_id", "topic", "severity"} (all optional except scope_id for business and firm) |
Mark every matching new notification seen, not only the page you loaded |
POST /notifications/dismiss-all |
Same, plus live_business_id |
Dismiss every matching open notification and the matching live alerts |
All four return {"updated": N}. The id-list routes are best effort: rows whose status does not allow the move are skipped and left out of N, and virtual ids count as updated. mark-all-seen and dismiss-all use scope (default me), topic and severity as filters. dismiss-all does not touch anomaly findings; to clear those, call POST /anomalies/dismiss-all.
Real-time stream
GET /api/v1/notifications/stream is a Server-Sent Events stream. It sends a notification event with the full notification each time one is created for you, and a comment heartbeat every 25 seconds. Authenticate with the same bearer token; the stream is not tied to one business. If the real-time layer is unavailable the stream stays open and idle, so keep polling /notifications/count as a fallback.
Attention items
/attention merges two sources into one list: persisted notifications and live alerts. It exists for the bell and the Action Items card; for filtering by topic, scope or status use /notifications.
Item ids are namespaced: inbox:<notification uuid> for a persisted notification, live:<alert id> for a live alert. Live alert ids are low_balance, overdue_bills, upcoming_bills, overdue_invoices, upcoming_invoices, uncategorized_transactions, plaid_connection_issue, plaid_connection_stale and anomaly_high_severity.
/attentionauthenticates the user but does not resolvex-business-id, so the service sees no business. Live alerts are only built when there is a business, which means this API currently returns persisted notifications only,live_alertsin the summary is0, and fetching alive:id is a400. UseGET /notificationswithlive_business_idto read live alerts.
curl "https://api.ondayzero.com/api/v1/attention?action_required=true&unread_only=true&limit=20" \
-H "Authorization: Bearer dz_your_token_here"| Query parameter | Description |
|---|---|
action_required |
Only items that need an action |
unread_only |
Hide read items (persisted status other than new) |
category |
action_required, alert, info, announcement or system |
priority |
urgent, high, normal or low |
entity_type |
Related entity kind |
include_live |
Merge live alerts (default true) |
client_view |
true to build action URLs under /client instead of /books |
limit, cursor |
limit 1 to 100, default 20; the response has next_cursor and has_more |
Items sort with unread action-required items first, then by priority, then newest first. Each item has id, source (inbox or live), category, priority, title, body, icon, action_required, action_url, action_label, entity_type, entity_id, created_at, read, starred (a pinned notification), dismissible and metadata. A notification with an action link is category action_required; otherwise warning and urgent severities are alert and the rest info. Notification severity maps to priority as urgent to urgent, warning to high, info to normal. The category, priority, entity_type and action_required filters are applied to the loaded page, so a filtered page can hold fewer than limit items. Responses are cached for about 30 seconds.
| Route | Effect |
|---|---|
GET /attention/summary |
{total, action_required, urgent, high, inbox_unread, live_alerts} for badges. inbox_unread counts open notifications (new and seen) |
GET /attention/{item_id} |
One item. auto_mark_read (default true) marks it seen |
POST /attention/{item_id}/read |
Marks a notification seen, or dismisses a live alert for you. Returns {"item": {...}} |
POST /attention/mark-all-read |
Marks every open notification seen and clears live dismissals. Returns {"count": N} |
POST /attention/clear-dismissals |
Restores live alerts you dismissed. Returns {"cleared": true} |
Notification preferences
Each user chooses, per topic, whether a notification is delivered on each channel. The channels are in_app, email and slack. A topic and channel you have not set fall back to DayZero's default for that topic and severity.
curl "https://api.ondayzero.com/api/v1/preferences/notifications/topics" \
-H "Authorization: Bearer dz_your_token_here"{
"overrides": {"anomaly": {"email": true, "slack": false}},
"available_channels": ["in_app", "email", "slack"],
"available_topics": [{"value": "anomaly", "label": "Anomaly"}, {"value": "pending_change", "label": "Proposed Changes"}]
}available_topics lists every topic with its display label. PUT replaces the whole override map for the calling user:
curl -X PUT "https://api.ondayzero.com/api/v1/preferences/notifications/topics" \
-H "Authorization: Bearer dz_your_token_here" \
-H "Content-Type: application/json" \
-d '{"overrides": {"anomaly": {"email": true, "in_app": true}, "billing": {"slack": false}}}'The body is a replacement, not a merge: topics you leave out lose any earlier override. Unknown channels, non-boolean values and topics left with no valid channel are dropped silently instead of rejected, so the response, which echoes the cleaned map, is the source of truth. Send {"overrides": {}} to clear everything. Overrides are per user; a business or advisory firm can also set its own policy, which is managed in the app and not through this route. The route is personal, so it needs no x-business-id.
Daily brief
GET /api/v1/books/daily-brief returns the payload behind the Books home screen. It is read-only and needs read permission on the business.
curl "https://api.ondayzero.com/api/v1/books/daily-brief" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"The response has as_of, business_id and one object per tile. Each tile is computed on its own, and has a status: ok, stale (its source data was missing or the calculation failed, so show nothing or a placeholder) or none (partner_credit: the business runs no credit program; agents_need_you: nothing waits on a person). A stale tile never fails the request.
| Tile | Contents |
|---|---|
categorization |
auto_categorized_today, sent_to_review_today (categorized automatically today and not yet confirmed by a person), top_rule ({id, name, match_count} or null) |
ap_drafts |
count, total_cents and top (up to 5 bills {bill_id, vendor_id, amount_cents, description, due_on}) for draft bills awaiting approval. Based on the 50 largest drafts, so count never exceeds 50 |
anomalies |
count of active findings, by_severity (high, medium, low), top (up to 5, most severe first, each with fingerprint, anomaly_type, severity, description, recommended_action, transaction_ids, entity_ids) and last_scanned. stale when no scan exists |
close_score |
current and previous (0 to 100, previous from the last earlier snapshot), delta, color (green from 90, yellow from 70, else red; gray when there is no score), blockers_count, period_id, period_display. Uses the most recent open accounting period, else the most recent period |
burn_runway |
avg_monthly_burn_cents (operating cash flow, trailing three full months; 0 when positive), cash_on_hand_cents (bank cash plus investment balances), runway_months (null when there is no burn or no cash) and months ([{label, net_change_cents}]) |
ai_spend |
current_month_cents, avg_monthly_cents and top_vendors (up to 3) for spend with known AI vendors. stale when there is none |
ar_outstanding |
count, total_cents, overdue_count, overdue_cents for open and partially paid invoices |
ap_outstanding |
The same four fields for approved bills not fully paid |
partner_credit |
lines_active, loans_open, loans_past_due, outstanding_cents, past_due_cents, due_within_7d_count, interest_income_mtd_cents, fee_income_mtd_cents, allowance_cents |
people |
From the People roster: current headcount, by_type (w2, contractor_us, contractor_offshore, unknown), new, notice, monthly_commitment_cents and without_commitment (people with no rate). null for callers who may not see the roster, monthly_commitment_cents is null for those who may see it but not pay, and stale when the roster is empty |
agents_need_you |
Close agent jobs for the month being closed that need a person: overdue_count, blocked_count, proposed_changes_count (changes agents filed that nobody has decided), total_steps, period_start, period_display, business_day_today and the overdue and blocked job lists (job_id, title, owner, status, due_business_day, overdue, blocker, evidence_url, updated_at). status is none when there is nothing to show |
All amounts are integer cents. The brief computes "today" in UTC.
GET /api/v1/books/standup builds the morning post from the same data: greeting (by hour, UTC), headline, summary_md (Markdown), highlights (four KPI tiles {label, value, tone, hint, href}), action_cards and the full brief. An action card has id, kind (such as anomaly_resolve, bill_approve, close_blockers), title, description, a primary_action and optional secondary_action and dismiss_action, each {label, method, endpoint, body}. An endpoint is either an API route to call or an in-app link to open, so check method and the prefix before you call it.
Not covered here
- The legacy inbox routes (tag
inbox), admin routes, and the Slack and email delivery integrations are not part of the public API. - The MCP server exposes tools for most of these families (
list_anomalies,dismiss_anomaly,create_pending_change,list_notifications,get_daily_briefand others). See MCP Server.