Skip to content

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

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

json
{
  "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

bash
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

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

bash
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
json
{
  "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

text
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

bash
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

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

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

  1. Open a round:
bash
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).

  1. File changes with POST /pending-changes and the round's id as round_id.
  2. Submit with POST /pending-change-rounds/{round_id}/submit (no body). The round stops taking changes and its approvers are told once. It is 409 if 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 an approval_round_reminder notification to the owner and admins and to the business's deputy approver.
  3. A signed-in person decides it:
bash
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

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

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

/attention authenticates the user but does not resolve x-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_alerts in the summary is 0, and fetching a live: id is a 400. Use GET /notifications with live_business_id to read live alerts.

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

bash
curl "https://api.ondayzero.com/api/v1/preferences/notifications/topics" \
  -H "Authorization: Bearer dz_your_token_here"
json
{
  "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:

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

bash
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_brief and others). See MCP Server.