Skip to content

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 403 with code BILLING_003.
  • Active billing. Like other write routes, POST, PUT, PATCH and DELETE also need an active trial or subscription (403 when a trial has ended and there is no subscription).
  • Token permission. GET needs read, POST/PUT/PATCH need write, DELETE needs delete. Two routes need admin: PATCH /accounting-periods/settings and POST /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 POST in these families returns 201 on success, including actions such as close, lock and sign-off. The one exception is creating a period that already exists (below), which returns 200.

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
text
open ── close ──▶ closed ── lock (2 different reviewers) ──▶ locked
  ▲                 │                                          │
  └──────────────── reopen (admin, with a reason) ◀────────────┘

List periods

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

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

bash
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

bash
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

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

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

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

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

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

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

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

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

json
{
  "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_count counts rows; reviewer_count counts distinct people. When reviewer_count reaches required_reviewers (2) and the period is already closed, the period is locked in the same call and locked is true. On an open period the sign-offs are kept and wait; they do not close it.
  • GET .../sign-offs returns {period_id, sign_offs: [{id, user_id, role, notes, signed_off_at, score_at_signoff}]}.
  • DELETE .../sign-offs?role=controller removes 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):

bash
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

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

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

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

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

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

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

text
queued ──▶ in_progress ──▶ ready
                      └──▶ failed

Queue a package

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

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

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