Closing the Books
Month-end close in DayZero is a control layer: it does not post journal entries, it decides when entries can still be posted. A closed or locked accounting period rejects new and edited transactions and journal entries dated inside it (400, VALIDATION_168, "Cannot modify transactions in a closed accounting period", the same rule described in Journal Entries). This guide covers three route families:
| Family | What it does |
|---|---|
/api/v1/accounting-periods |
Create periods, close, lock and reopen them, the close score and blockers, checklist acknowledgements, reviewer sign-offs, auto-create and close-start settings, the close run |
/api/v1/close |
Close verification: one report of every exception in a period, plus a fixer for miscategorized rows |
/api/v1/month-end-packages |
Build and download a .zip of month-end reports for a period |
The Monthly Close Checklist page in the app is built on these routes; see the Accounting Periods article in the Knowledge Base for the UI view.
Access and permissions
- CFO Suite add-on. Every route in all three families requires the CFO Suite add-on (included in a trial, and bundled in the firm Professional and Enterprise plans). Without it the response is
403with codeBILLING_003. - Active billing. Like other write routes,
POST,PUT,PATCHandDELETEalso need an active trial or subscription (403when a trial has ended and there is no subscription). - Token permission.
GETneeds read,POST/PUT/PATCHneed write,DELETEneeds delete. Two routes need admin:PATCH /accounting-periods/settingsandPOST /accounting-periods/{period_id}/reopen. Reopening additionally requires the caller to be an owner or admin of the business (403,AUTH_048, "admin or owner required to reopen accounting periods"). - Every
POSTin these families returns201on success, including actions such as close, lock and sign-off. The one exception is creating a period that already exists (below), which returns200.
Responses use the standard envelope (success, message, code, data) and errors the shape in Error Handling. Dates are YYYY-MM-DD; timestamps such as closed_at are UTC without an offset.
Period status
| Status | Posting inside the period | How it is reached |
|---|---|---|
open |
Allowed | New periods; after a reopen |
closed |
Rejected | POST /accounting-periods/close |
locked |
Rejected (strongest protection) | POST /accounting-periods/{period_id}/lock, the sign-off policy, or bulk lock |
open ── close ──▶ closed ── lock (2 different reviewers) ──▶ locked
▲ │ │
└──────────────── reopen (admin, with a reason) ◀────────────┘List periods
curl "https://api.ondayzero.com/api/v1/accounting-periods?status=closed&limit=12" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Query parameters: status (open, closed or locked; anything else is a 400, VALIDATION_097), search (case-insensitive match on status, dates and lock or reopen reasons), plus the standard cursor parameters (limit, cursor, direction, sort_by, descending; see Pagination). The default order is newest period_end first.
{
"success": true,
"data": {
"items": [
{
"id": "01952c2e-7e7f-7c8e-9f00-000000000001",
"business_id": "YOUR_BUSINESS_ID",
"period_start": "2026-09-01",
"period_end": "2026-09-30",
"status": "closed",
"closed_at": "2026-10-04T15:12:09.221000",
"closed_by_user_id": "01952c2e-7e7f-7c8e-9f00-0000000000a1",
"lock_reason": "September close complete",
"reopen_reason": null,
"reopened_at": null,
"reopened_by_user_id": null,
"created_at": "2026-09-01T00:05:00",
"updated_at": "2026-10-04T15:12:09"
}
],
"next_cursor": null,
"prev_cursor": null,
"has_next": false,
"has_prev": false
}
}GET /api/v1/accounting-periods/{period_id} returns one period in the same shape (404 if it does not exist or belongs to another business).
Create a period
curl -X POST "https://api.ondayzero.com/api/v1/accounting-periods" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"period_start": "2026-10-01", "period_end": "2026-10-31"}'Any date range works; a calendar month is the usual choice. period_end must be after period_start (400, VALIDATION_093). The call is idempotent on the exact range: if a period with the same start and end exists, it is returned unchanged with status 200 and "created": false. A range that overlaps an existing period in any other way is a 400 (VALIDATION_550) whose message names the period in the way. A new period is open, and an accounting_period.created webhook fires (see Webhooks).
Auto-create settings
curl "https://api.ondayzero.com/api/v1/accounting-periods/settings" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"
curl -X PATCH "https://api.ondayzero.com/api/v1/accounting-periods/settings" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"auto_create_periods": true, "auto_close_prep_enabled": true, "auto_close_prep_day": 3, "agent_close_business_day": 3}'| Field | Description |
|---|---|
auto_create_periods |
Required on PATCH. When true, a nightly job opens the period for the month that just ended and the current month (UTC) if they are missing, and fires accounting_period.created with created_by: "auto". A month that an existing period already covers or cuts across is skipped |
auto_close_prep_enabled |
Optional. When true, a daily job prepares the close of the month that just ended: it dry-runs Regent's close checks, files the close as a proposed change for a person to approve, and notifies the owner and admins. Nothing closes without that approval |
auto_close_prep_day |
Optional, 1 to 28 (UTC). The day of the month the preparation runs; defaults to 1. Omit either optional field to leave it unchanged |
agent_close_business_day |
Optional. The business day of the month (1 to 23, or -1 for the last) on which the close of the month that just ended starts: DayZero sends the close.window_opened webhook that day, counting business days on the Fed bank-holiday calendar and the New York date. null or 0 turns it off; omit it to leave it unchanged. Any other value is 400 VALIDATION_840 |
GET returns all four fields. Both calls are scoped to the business in x-business-id; PATCH needs admin permission. The same agent_close_business_day setting can be changed with PUT /api/v1/businesses/{business_id} (see Businesses).
Close a period
curl -X POST "https://api.ondayzero.com/api/v1/accounting-periods/close" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"period_end": "2026-09-30", "lock_reason": "September close complete"}'Closing is addressed by date, not period ID: the period that contains period_end is closed. If no period contains that date, a monthly period (first to last day of that month) is created and closed. The response is the period with status: "closed", closed_at and closed_by_user_id set; lock_reason is stored as the period's reason.
An already closed or locked period is a 400 (VALIDATION_167, "Period is already closed").
Close blockers
Before closing, the period's close score is recomputed and any high-severity blocker refuses the close with 400 (VALIDATION_780). The message lists the blockers and the errors map holds one entry per blocker:
{
"error": "validation_error",
"message": "This period cannot be closed yet: 34 transactions need review; 12 transactions are uncategorized. Resolve them, or close with force=true and an override_reason (audit-logged).",
"errors": {
"txn_review_0": "34 transactions need review",
"categorization_1": "12 transactions are uncategorized"
},
"code": "VALIDATION_780",
"request_id": "3f0e7c2a-4b1d-4a8e-9c2f-1d2e3f4a5b6c"
}To close anyway (for example a historical month reviewed offline), send force: true with an override_reason (up to 1000 characters, at least 3 after trimming; lock_reason is accepted as a fallback). Without a reason the call is a 400 (VALIDATION_781). The reason and the blockers that were bypassed are written to the audit log.
curl -X POST "https://api.ondayzero.com/api/v1/accounting-periods/close" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"period_end": "2026-09-30", "force": true, "override_reason": "Reviewed offline before migration"}'force only bypasses blockers. It does not reopen an already closed period.
Close run
When the close of a month starts, the close agents (the Agent window) work from a list of jobs, each with an owner, a business day it is due by and a definition of done. The close run is the record of those jobs for one month, read-only:
curl "https://api.ondayzero.com/api/v1/accounting-periods/close-run?period_start=2026-09-01" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"period_start is the first day of the month being closed (YYYY-MM-01; YYYY-MM also works) and defaults to the previous month by the New York date; anything else is a 400. GET /api/v1/accounting-periods/{period_id}/close-run returns the run for the month a period starts in.
{
"success": true,
"data": {
"period_start": "2026-09-01",
"steps": [
{
"id": "01952c2e-7e7f-7c8e-9f00-0000000000f1",
"job_id": "bank-rec",
"title": "Reconcile bank accounts",
"owner": "bookkeeper",
"status": "blocked",
"due_business_day": 3,
"overdue": true,
"done_when": "Every bank account reconciled through month end",
"done_when_result": "Checking is missing the September statement",
"evidence_url": null,
"blocker": "Waiting on the September statement",
"run_id": null,
"bot": null,
"cost_usd": null,
"checklist_step_id": null,
"started_at": "2026-10-02T14:00:00",
"finished_at": null,
"updated_at": "2026-10-04T09:30:00"
}
],
"summary": {"total": 12, "done": 7, "blocked": 1, "failed": 0, "overdue": 1, "business_day_today": 4}
}
}A step's status is planned, running, done, blocked, failed or skipped. It is overdue when it is not done or skipped and its due_business_day has passed (-1 means the last business day; once the closing month is over every open step with a due day is overdue). DayZero, not the agent, decides lateness. A step that names a checklist_step_id acknowledges that checklist step when it is done. Steps are ordered by due day. Each change sends a close.progress webhook, and a daily check sends close_step.overdue once per late step per day and notifies the owner and admins (notification topic agent_close_overdue). The Daily Brief's agents_need_you tile summarizes the same data (see Anomalies, Alerts and Proposed Changes).
Close score and blockers
The close score is a 0 to 100 gauge of how ready a period is. GET returns the latest stored score and computes one if none exists yet; it does not refresh a stored score on its own.
curl "https://api.ondayzero.com/api/v1/accounting-periods/{period_id}/close-score" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"{
"success": true,
"data": {
"id": "01952c2e-7e7f-7c8e-9f00-0000000000b7",
"period_id": "01952c2e-7e7f-7c8e-9f00-000000000001",
"score": 82,
"color": "yellow",
"components": {
"txn_review": {"weight": 25, "max": 25, "scored": 21, "details": {"unreviewed_count": 34, "transaction_count": 210}, "blockers": []},
"categorization": {"weight": 20, "max": 20, "scored": 17, "details": {}, "blockers": []}
},
"blockers": [
{"category": "txn_review", "severity": "high", "label": "34 transactions need review", "count": 34, "action_url": "/transactions?review_status=unreviewed"}
],
"last_computed_at": "2026-10-04T15:10:00"
}
}(components is shortened here.) color is green at 90 and above, yellow from 70, otherwise red. The score is the sum of these components:
| Component | Points | What it measures |
|---|---|---|
txn_review |
25 | Share of the period's transactions that are reviewed |
categorization |
20 | Share that are categorized |
rule_quality |
5 | Bank-rule override rate over the trailing 30 days: full marks at 5% or below, zero at 25% or above, and full marks when no rule matched |
reconciliation |
20 | Share of the period's transactions that are reconciled |
checklist |
20 | Manual checklist steps acknowledged (nine steps, below) |
anomalies |
10 | Loses 2 points per high-severity finding in the latest anomaly scan |
verification |
0 | Not scored: adds the close verification exceptions to the blockers list |
blockers is ordered high, then medium, then low severity. Each has category, severity, label and usually a count, plus extra keys depending on the category (step_id, check, rule_id, action_url). action_url is an in-app path, not an API route. Blockers are high when:
- 20 or more transactions are unreviewed (below 20 it is
medium); - 10 or more transactions are uncategorized;
- 20 or more transactions are unreconciled;
- there is an unresolved high-severity anomaly;
- a close verification check found at least one
blockingrow.
Missing checklist steps and noisy bank rules are medium and never stop a close. Verification checks with only review rows are medium.
Related routes, all under /api/v1/accounting-periods/{period_id}:
| Route | Description |
|---|---|
POST /close-score/recompute |
Re-run scoring and store the result. Call it after resolving a blocker so the gauge updates immediately. Returns the same shape as GET /close-score |
GET /blockers |
Only period_id, score, color and the ordered blockers ("what is between you and 100%") |
GET /score-history?limit=60 |
Daily snapshots, oldest first, as {period_id, history: [{snapshot_date, score, color}]}. limit is 1 to 365. One snapshot is kept per UTC day, written when the score is recomputed |
POST /close-readiness |
A separate, lighter check: {score, items, summary} where each item is {check, status, detail, count} with status of pass, warning or fail. It looks at unreviewed and uncategorized transactions and expenses missing receipts, and returns an All Clear item when there is nothing outstanding. summary carries transaction_count, unreviewed_count, uncategorized_count and missing_receipt_count. It is a POST (so it needs write permission) but changes nothing |
close-readiness and close-score use different formulas and will not always agree; the close score is the one that gates POST /close.
Checklist acknowledgements
Some review steps cannot be computed, so a person acknowledges them. Each acknowledgement counts toward the checklist component (nine steps, each worth an equal share of the 20 points).
step_id |
Meaning |
|---|---|
trial_balance_review |
Trial balance reviewed |
pnl_review |
P&L variance reviewed |
balance_sheet_review |
Balance sheet reviewed |
controller_final_review |
Controller's final review |
revenue_recognition |
Revenue recognition |
accruals |
Accruals |
depreciation |
Depreciation |
payroll_entries |
Payroll entries |
intercompany |
Intercompany |
curl -X PUT "https://api.ondayzero.com/api/v1/accounting-periods/{period_id}/checklist-acks/trial_balance_review" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"notes": "Reviewed with the controller"}'The call is an upsert, so repeating it updates notes. An unknown step_id is a 400 (VALIDATION_268). The response is {id, business_id, period_start, step_id, notes, acked_by, created_at, updated_at}. GET .../checklist-acks lists them as {period_id, period_start, items: [...]}, and DELETE .../checklist-acks/{step_id} clears one so the step is pending again. Acknowledgements are keyed to the period's start date, so they survive a reopen.
Acknowledging a step does not recompute the score on its own; call POST .../close-score/recompute afterwards.
Sign-offs and locking
Locking is stronger than closing and needs sign-offs from at least two different people (users). A sign-off is a row of (user, role): one person signing as both controller and reviewer is still one reviewer.
curl -X POST "https://api.ondayzero.com/api/v1/accounting-periods/{period_id}/sign-offs" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"role": "controller", "notes": "Numbers tie to the bank statements"}'role is free text of 1 to 40 characters; the app uses values such as preparer, reviewer, controller, owner and partner, and the API does not restrict them. notes is optional. Any caller with write permission can sign off, as themselves.
{
"success": true,
"data": {
"sign_off_count": 2,
"reviewer_count": 2,
"required_reviewers": 2,
"locked": true,
"id": "01952c2e-7e7f-7c8e-9f00-0000000000c3",
"user_id": "01952c2e-7e7f-7c8e-9f00-0000000000a1",
"role": "controller",
"notes": "Numbers tie to the bank statements",
"signed_off_at": "2026-10-05T09:30:00",
"score_at_signoff": 96
}
}- Signing the same role again as the same user refreshes the notes, timestamp and
score_at_signoff(the score the reviewer was looking at). - Each sign-off triggers a score recompute.
sign_off_countcounts rows;reviewer_countcounts distinct people. Whenreviewer_countreachesrequired_reviewers(2) and the period is alreadyclosed, the period is locked in the same call andlockedistrue. On anopenperiod the sign-offs are kept and wait; they do not close it.GET .../sign-offsreturns{period_id, sign_offs: [{id, user_id, role, notes, signed_off_at, score_at_signoff}]}.DELETE .../sign-offs?role=controllerremoves the caller's own sign-off for that role and recomputes the score. It does not reopen a locked period.
To lock explicitly (for example when the sign-offs came before the close):
curl -X POST "https://api.ondayzero.com/api/v1/accounting-periods/{period_id}/lock" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"The request has no body. It returns the period with status: "locked". Errors, all 400: the period is already locked (VALIDATION_094), the period is still open (VALIDATION_095, close it first), or fewer than two different reviewers have signed off (VALIDATION_331, "At least 2 (1 different reviewer recorded) sign-offs from different reviewers are required before locking.").
Reopen a period
curl -X POST "https://api.ondayzero.com/api/v1/accounting-periods/{period_id}/reopen" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"reopen_reason": "Correct a January accrual"}'Works on closed and locked periods. reopen_reason is required and is trimmed (a blank reason is a 400). The period returns to open with reopen_reason, reopened_at and reopened_by_user_id set; reopening a locked period clears its lock_reason. An already open period is a 400 (VALIDATION_096). The reason, the previous status and the transition are written to the audit log. Existing sign-offs are kept, so close and lock again after you finish; to require fresh reviews, revoke the old sign-offs.
Bulk lock for historical periods
For onboarding a business with years of reviewed history:
curl -X POST "https://api.ondayzero.com/api/v1/accounting-periods/bulk-lock" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"period_ids": ["PERIOD_UUID_1", "PERIOD_UUID_2"], "lock_reason": "Pre-migration history"}'period_ids takes 1 to 120 IDs (duplicates are dropped). Each open period is closed and locked in one step, without the blocker check or the two-reviewer sign-off rule; a closed period is locked. lock_reason defaults to "Historical migration - bulk locked". The response:
{
"success": true,
"data": {
"locked_count": 1,
"skipped_count": 1,
"locked_ids": ["PERIOD_UUID_1"],
"skipped": [{"period_id": "PERIOD_UUID_2", "reason": "already_locked"}]
}
}Skip reasons: already_locked, not_found (unknown or another business's ID), or an error: ... / db_error: ... message. After a database error the rest of the batch is reported as skipped so you can retry them in a new call.
Close verification
Close verification answers "what is wrong with this period?" in one call.
curl "https://api.ondayzero.com/api/v1/close/verification?start_date=2026-09-01&end_date=2026-09-30&checks=cash_inflows&checks=transfer_pairing&max_rows_per_check=50" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"| Parameter | Description |
|---|---|
start_date, end_date |
Required, YYYY-MM-DD. The range may be at most 400 days and start_date cannot be after end_date (400, VALIDATION_531). The dates are free; they do not have to match an accounting period |
checks |
Which checks to run. Repeat the parameter (checks=a&checks=b); omit it to run all six. An unknown name is a 400 (VALIDATION_530) listing the valid ones |
max_rows_per_check |
Default 100, 0 to 5000. Caps the rows returned per check; counts always cover every row. 0 returns counts only |
| Check | Finds |
|---|---|
uncategorized_transactions |
Bank and card rows still on the Uncategorized buckets or with no journal entry |
miscategorized_candidates |
Categorized rows whose ledger disagrees with their counterparty default or matching bank rule |
transfer_pairing |
Transfer legs with no opposite partner, and likely transfers booked as income or expense |
counterparty_mismatch |
Processor rows (payroll, PayPal and similar) whose counterparty is the processor although the description names a payee, and rows whose counterparty shares no word with the description |
cash_inflows |
Every positive bank inflow, grouped as customer receipt, investor note, refund, transfer or other, flagging those that need a match |
manual_journal_entries |
Manual entries split into hand-typed and bulk-uploaded, reclass or adjust entries, and unbalanced or same-ledger entries |
The response has business_id, period_start, period_end, checks_run, a summary and a checks array:
{
"success": true,
"data": {
"business_id": "YOUR_BUSINESS_ID",
"period_start": "2026-09-01",
"period_end": "2026-09-30",
"checks_run": ["transfer_pairing"],
"summary": {
"status": "blocking",
"total_exceptions": 3,
"blocking_count": 2,
"review_count": 1,
"total_amount_cents": 1250000,
"checks": {"transfer_pairing": {"count": 3, "blocking_count": 2, "review_count": 1, "status": "blocking"}}
},
"checks": [
{
"check": "transfer_pairing",
"title": "Transfer pairing",
"status": "blocking",
"count": 3,
"blocking_count": 2,
"review_count": 1,
"info_count": 0,
"total_amount_cents": 1250000,
"counts_by_issue": {"unpaired_transfer_leg": 2},
"suggested_fix": "Re-categorize an unpaired leg to what it really was, or categorize both legs of a pair to Transfers Between Accounts.",
"rows_truncated": false,
"rows": [
{
"check": "transfer_pairing",
"issue": "unpaired_transfer_leg",
"severity": "blocking",
"transaction_id": "01952c2e-7e7f-7c8e-9f00-0000000000d4",
"date": "2026-09-14T00:00:00",
"amount_cents": -500000,
"description": "ONLINE TRANSFER TO SAV",
"why": "Booked to Transfers Between Accounts but no opposite-sign equal amount exists within the matching window on another account.",
"suggested_fix": "Re-categorize to what the money really was, or add the missing leg on the other account."
}
]
}
]
}
}(Shortened; the exact issue values and wording vary by check, and rows carry more optional fields such as counterparty, current_ledger, expected_ledger and related_ids.) Severity per row is blocking (fix before closing), review (needs a human look) or info (listed for completeness). count is blocking plus review rows; info rows are counted separately. status rolls up to the worst severity. amount_cents is signed cash flow for a transaction (positive is money in) and total debits for a journal entry. Row suggested_fix text may name an assistant tool; in the API, use the transaction and counterparty routes in Transactions.
These same counts feed the close score's verification blockers.
Apply counterparty defaults
POST /api/v1/close/verification/apply-counterparty-defaults moves already-categorized rows in a period to the ledger their counterparty default names. It is a preview unless you set dry_run=false, and it needs write permission.
# 1. Preview (dry_run defaults to true)
curl -X POST "https://api.ondayzero.com/api/v1/close/verification/apply-counterparty-defaults?start_date=2026-09-01&end_date=2026-09-30" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"
# 2. Apply exactly what the preview showed
curl -X POST "https://api.ondayzero.com/api/v1/close/verification/apply-counterparty-defaults?start_date=2026-09-01&end_date=2026-09-30&dry_run=false&plan_hash=PLAN_HASH_FROM_PREVIEW" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"All parameters are query parameters, not a JSON body: start_date and end_date (required), dry_run (default true), counterparty (only rows for that counterparty), include_user_categorized (default false: rows a person categorized by hand are skipped), and plan_hash. Rows in a closed period and rows whose default ledger cannot take the direction of the money are skipped and counted.
The response has dry_run, plan_hash, eligible_count (rows that would move, or moved), moved_count, failed_count, skipped_counts (by reason), by_target_ledger, and moves (up to 500, each with transaction_id, from_ledger, to_ledger, amount_cents and a status of will_move, moved, skipped_user_categorized, skipped_direction, skipped_closed_period or failed); moves_truncated is true if more exist. Pass the preview's plan_hash back when applying: if the rows changed in between, the apply is refused with 400 (VALIDATION_640) and you should preview again. plan_hash is optional, but without it nothing guards against drift.
Month-end packages
A month-end package is one .zip with a file per selected report for a period. It is built in the background.
queued ──▶ in_progress ──▶ ready
└──▶ failedQueue a package
curl -X POST "https://api.ondayzero.com/api/v1/month-end-packages" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"accounting_period_id": "01952c2e-7e7f-7c8e-9f00-000000000001",
"report_format": "xlsx",
"selected_reports": ["pl", "balance_sheet", "cash_flow", "ar_aging", "ap_aging"]
}'| Field | Description |
|---|---|
accounting_period_id |
The period to cover. Takes precedence over the dates; an ID from another business is a 404 |
period_start, period_end |
Required when accounting_period_id is omitted. Any range with period_end after period_start (otherwise 400, VALIDATION_093) |
report_format |
pdf (default) or xlsx, applied to every report in the zip |
selected_reports |
Required, at least one of pl, balance_sheet, cash_flow, ar_aging, ap_aging, budget_vs_actual, bank_reconciliation. Case is ignored and duplicates are dropped; an unsupported key is a 400 naming it |
The package does not require the period to be closed. The response is 201 with the new package and the background workflow ID; the package starts as queued:
{
"success": true,
"message": "Month-end package is being prepared. We'll notify you when it's ready.",
"data": {
"package": {
"id": "01952c2e-7e7f-7c8e-9f00-0000000000e5",
"business_id": "YOUR_BUSINESS_ID",
"accounting_period_id": "01952c2e-7e7f-7c8e-9f00-000000000001",
"period_start": "2026-09-01T00:00:00",
"period_end": "2026-09-30T00:00:00",
"status": "queued",
"report_format": "xlsx",
"selected_reports": ["pl", "balance_sheet", "cash_flow", "ar_aging", "ap_aging"],
"workflow_id": "month-end-package-YOUR_BUSINESS_ID-01952c2e-7e7f-7c8e-9f00-0000000000e5",
"file_size_bytes": null,
"filename": "month-end-package_2026-09-01_to_2026-09-30.zip",
"error_message": null,
"requested_by_user_id": "01952c2e-7e7f-7c8e-9f00-0000000000a1",
"started_at": null,
"ready_at": null,
"created_at": "2026-10-05T10:00:00",
"updated_at": "2026-10-05T10:00:00"
},
"workflow_id": "month-end-package-YOUR_BUSINESS_ID-01952c2e-7e7f-7c8e-9f00-0000000000e5"
},
"id": "01952c2e-7e7f-7c8e-9f00-0000000000e5"
}When the build finishes, the requesting user also gets an in-app inbox message with a download link.
Poll and list
GET /api/v1/month-end-packages/{package_id} returns the package (poll it until status is ready or failed; error_message explains a failure). GET /api/v1/month-end-packages lists packages newest first with a status filter (queued, in_progress, ready, failed; anything else is a 400) and the standard cursor parameters.
Download
curl "https://api.ondayzero.com/api/v1/month-end-packages/{package_id}/download" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"{
"success": true,
"data": {
"package_id": "01952c2e-7e7f-7c8e-9f00-0000000000e5",
"filename": "month-end-package_2026-09-01_to_2026-09-30.zip",
"download_url": "https://...",
"expires_at": "2026-10-05T11:05:00",
"file_size_bytes": 482113
}
}download_url is a presigned link that expires after one hour (expires_at); call the route again for a fresh one. It is not an authenticated API route, so fetch it without your bearer token. A package that is still queued or in_progress is a 400 ("still being prepared", try again shortly); a failed one is a 400 with the failure reason.
Errors at a glance
| Status | Code | When |
|---|---|---|
400 |
VALIDATION_093 |
period_end is not after period_start (periods and packages) |
400 |
VALIDATION_550 |
New period overlaps an existing one (not the exact same range) |
400 |
VALIDATION_780 / VALIDATION_781 |
Close refused by blockers / forced close without a reason |
400 |
VALIDATION_167 |
Close on a period that is already closed or locked |
400 |
VALIDATION_094 / 095 / 331 |
Lock: already locked / still open / fewer than two different reviewers |
400 |
VALIDATION_096 |
Reopen on an open period |
400 |
VALIDATION_268 |
Unknown checklist step_id |
400 |
VALIDATION_530 / 531 |
Unknown verification check / invalid verification range |
400 |
VALIDATION_640 |
Stale plan_hash when applying counterparty defaults |
403 |
BILLING_003 |
CFO Suite add-on missing |
403 |
AUTH_048 |
Reopen by a caller who is not an owner or admin, or a token without the needed permission |
404 |
NOT_FOUND_* |
Period or package does not exist for this business |
Not covered here
This guide leaves out the assistant-driven close flows (Regent's month-end close, which files a close as a pending change for approval), the bank-rule audit that feeds rule_quality, and the internal routes the Monthly Close Checklist page uses beyond those above. See Reconciliation for the reconciliation component and Reports for the reports behind each package entry.