AP Workflows
The Bills and Vendors guides cover creating bills, recording payments and managing vendor records. This guide covers the accounts payable routes around them:
| Area | Base path | Add-on |
|---|---|---|
| AP approval | /api/v1/ap-approval |
AP Automation |
| Vendor contracts | /api/v1/vendor-contracts |
AP Automation |
| Payment advisory | /api/v1/payment-advisory |
AP Automation |
| Pay lists and tie-out | /api/v1/bills/payment-run, /tie-out; the ranked pay plan |
none |
| Vendor credits | /api/v1/vendor-credits |
none |
| Vendor bill-request links | /api/v1/vendor-bill-requests, /api/v1/public/bill-requests/{token} |
none |
| Contractors and 1099 | /api/v1/contractors |
none |
| Purchase orders | /api/v1/purchase-orders |
Commerce & Inventory |
| Expense reports and purchase requisitions | /api/v1/expense-reports, /api/v1/purchase-requisitions |
none (REST only; both modules are parked in the app) |
All requests send Authorization: Bearer dz_your_token_here and x-business-id: YOUR_BUSINESS_ID. Money is integer cents unless a field says otherwise. Responses use the {"success": true, "data": …} envelope and errors follow Error Handling. Lists are cursor-paginated (see Pagination) unless noted.
Permissions. Unless a route says otherwise, GET needs read, POST / PUT / PATCH need write and DELETE needs delete. Routes marked admin need the admin permission, which business owners and admins hold and which advisory-firm staff hold on their clients.
Add-ons. A route behind an add-on returns 403 with code BILLING_003 when the business does not have it. The add-on is satisfied by an active trial or demo workspace, by a paid add-on, or by the advisory firm's plan: AP Automation and Commerce & Inventory are both bundled with the Firm Enterprise tier.
Not covered here: the workflow-run endpoints that deliver a single approve or reject decision to an approval step (they are not part of the public API; the MCP server exposes approve_workflow_step and reject_workflow_step), and the Bill Inbox.
AP approval
AP approval holds a bill in pending_approval, with no journal entry, until the right people sign off. Approval then posts the bill to received (expense debit, accounts payable credit). A rejection moves it to canceled. Bill-side calls (submit-for-approval, submit-for-ap-approval, resolve-approval, revert-to-draft) are in Bills.
What has to be true
Three things must all hold before a bill is routed:
- The policy exists. The approval policy belongs to the advisory firm that manages the business, so thresholds, approvers and timeouts are shared by all of that firm's clients.
POST /api/v1/ap-approval/provisionfails with404for a business with no managing firm. - The business switch is on.
PUT /api/v1/businesses/{business_id}(admin) with{"ap_approval_enabled": true};business_idmust be the business inx-business-id. It is off by default, and while off, bills skip approval entirely. - The AP Automation add-on is active.
If the switch is on but no policy is published, submit-for-approval still moves the bill to pending_approval and nothing routes it. Provision first, or clear the bill with POST /api/v1/bills/{bill_id}/resolve-approval.
How a bill is routed
When a bill is sent for approval DayZero works out its contract match (see Vendor contracts) and its amount (amount, else expected_amount), then looks for a vendor route.
- Vendor route first. If the bill's vendor has a matching vendor approval route, the bill waits on that route's approvers (tier
vendor) instead of the amount tiers. After they approve, a bill whose contract match isover_contractorneeds_reviewgoes on to the advisor step and then the owner step; any other bill posts. - Otherwise by amount. Branches are tested in this order and the first true one wins:
| Order | Condition | Goes to |
|---|---|---|
| 1 | amount is at or below auto_approve_max_cents and contract match is matched |
Auto-approved: posts to received with no human step |
| 2 | amount is above auto_approve_max_cents and at or below ops_approve_max_cents |
Operations step (tier ops) |
| 3 | amount is above ops_approve_max_cents, or contract match is over_contract or needs_review |
Advisor step (tier advisor), then Owner step (tier owner) |
| default | nothing above matched | Advisor step, then Owner step |
Defaults are 50000 cents ($500) for auto_approve_max_cents and 500000 cents ($5,000) for ops_approve_max_cents. Two consequences of the order:
- A small bill whose contract match is anything but
matched(no_contract,under_contract,unknown, and so on) reaches the advisor step through the default. - A contract issue only escalates bills at or below the auto-approve limit. A bill between the two limits goes to Operations whatever its contract match, even though the third branch's label reads "or contract issue".
Step names, actions and default timeouts:
| Step | Label | Actions | Default timeout |
|---|---|---|---|
ops |
Operations Manager Approval | Approve, Reject (comment required) | 3 days |
advisor |
Advisor Review | Approve, Reject (comment required), Approve with Notes (comment required; continues to the owner step like Approve) | 7 days |
owner |
Owner Final Approval | Approve Payment, Defer Payment (comment required; cancels the bill) | none |
vendor |
<vendor name> approver |
Approve, Reject (comment required) | 3 days |
A timeout never approves or rejects. It re-alerts the step's approvers once and the step keeps waiting.
Who can decide a step
- A step with named approvers accepts only those users, or users assigned to the same tier. Vendor steps accept only the route's approvers; there is no fallback.
- A step with no approvers accepts business owners and admins and staff (owner, admin or member) of the managing advisory firm. Firm
clientandviewerroles cannot decide. - A person refused gets
403with a message naming the step and who may decide it. - If the same person is an approver for consecutive steps, their approval carries through to the next step instead of asking again.
Set up the policy
curl -X POST "https://api.ondayzero.com/api/v1/ap-approval/provision?auto_approve_max_cents=50000&ops_approve_max_cents=500000" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Both query parameters are optional (integers, at least 0). ops_approve_max_cents must be greater than auto_approve_max_cents (400 otherwise). The call is idempotent: it creates the policy if the firm has none and always rewrites the thresholds to the values sent, so omit them only if you want the defaults back. It returns workflow_id, name, is_published, version, graph and both thresholds. GET /api/v1/ap-approval returns {"enabled": false, "workflow": null} until a policy exists, then {"enabled": true, "workflow": {…}} with the same fields (id in place of workflow_id, plus description). Note that enabled here means the policy is published, not that this business's switch is on.
Change limits and the reminder timeout later with:
curl -X PATCH "https://api.ondayzero.com/api/v1/ap-approval/thresholds" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"auto_approve_max_cents": 25000, "ops_approve_max_cents": 1000000, "escalation_hours": 48}'Admin. Every field is optional and omitted fields keep their value. escalation_hours is 1 to 720 and sets the timeout on every approval step except the owner step. The ops > auto check runs only when both limits are in the same request, so send both whenever you change either. Changes apply to bills sent afterward, and to every client of the firm. The response is workflow_id, both thresholds and escalation_hours (read from the Operations step).
Name approvers for a step with PUT /api/v1/ap-approval/approvers/{tier} (admin), where tier is ops, advisor or owner:
curl -X PUT "https://api.ondayzero.com/api/v1/ap-approval/approvers/ops" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"user_ids": ["USER_UUID_1", "USER_UUID_2"]}'The list replaces the step's approvers; an empty list returns that step to the owner, admin and firm-staff rule. An unknown tier, or a firm with no policy, is a 400. GET /api/v1/ap-approval/approvers returns {"approvers": {"ops": [...], "advisor": [...], "owner": [...]}} (vendor steps are not listed).
Settings and health
GET /api/v1/ap-approval/thresholds reads the policy back as it actually routes, and checks it:
| Field | Meaning |
|---|---|
enabled |
A policy exists (without one the response is just enabled: false, tiers: [], warnings: []) |
auto_approve_max_cents, ops_approve_max_cents |
The limits parsed from the routing conditions |
auto_approve_rule |
The auto-approve rule in words |
branches |
The routing branches as id, label, condition |
tiers |
Each approval step: tier, node_id, label, approvers, reachable, timeout_seconds, waiting_count |
warnings |
Problems found, each with a kind |
vendor_routes_count |
Active vendor routes, which run before the tiers |
labels_fixed |
true when branch labels that had drifted from their conditions were corrected on this read |
Warning kinds: empty_tier (a step bills can reach has no named approvers; the message names how many bills wait there), ops_ceiling (the Operations limit is above $1,000,000, so almost nothing reaches advisor review) and label_drift. Despite being a GET, this call can rewrite stale labels.
GET /api/v1/ap-approval/analytics returns queue health: pending_count, pending_by_tier, avg_pending_age_hours, oldest_pending_hours (the last two are null for an empty queue) and workflow_enabled.
Work the queue
curl "https://api.ondayzero.com/api/v1/ap-approval/pending?mine=true&tier=ops" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"{
"success": true,
"data": {
"items": [
{
"execution_id": "019ab37c-ae0d-7000-8000-000000000a01",
"workflow_id": "019ab37c-ae0d-7000-8000-000000000b01",
"node_id": "ops_approval_gate",
"tier": "ops",
"label": "Operations Manager Approval",
"suspended_at": "2026-10-09T14:03:11+00:00",
"needs_routing": false,
"bill": {
"id": "019ab37c-b111-7000-8000-000000000001",
"amount": 125000,
"vendor_id": "019ab37c-ae0d-7000-8000-000000000001",
"vendor_name": "Acme Hosting",
"description": "October hosting",
"status": "pending_approval"
}
}
]
}
}tierfilters toops,advisororowner(vendor-route items show tiervendor).mine=truekeeps only items whose tier lists the caller as an approver; because it matches on tier approver lists, it never returns vendor-route items.- Up to 200 items, newest first. The list is cached for about 60 seconds per user and refreshed when approvals change.
- An item with
execution_id: nullandneeds_routing: trueis apending_approvalbill with no live run. Reading the queue starts routing for it, and only the unfiltered view lists it. It cannot be acted on until it has a step. GET /api/v1/ap-approval/completed?limit=50(1 to 200) lists runs that left the queue withexecution_id,tier,action(approve,reject, ormodifyfor Approve with Notes),comment,completed_atandbill. Auto-approved bills never reach a human, so they are not listed.GET /api/v1/ap-approval/vendor/{vendor_id}/history?limit=10(1 to 50) returnsvendor_name,recent_bills(id,amount,status,description,bill_number,created_at),total_paid_cents(over the bills returned) andbill_count, for context at decision time.POST /api/v1/ap-approval/risk-scoreasks AI to score up to 30 recentdraftandreceivedbills. Each item hasbill_id,vendor_name,risk_score(0 to 100, higher is riskier) and up to threerisk_factors.amounton these items is the model's echo in dollars, not cents; do not use it for arithmetic.
Approve and reject
curl -X POST "https://api.ondayzero.com/api/v1/ap-approval/batch-approve" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"execution_ids": ["019ab37c-ae0d-7000-8000-000000000a01"], "comment": "Matches PO"}'execution_ids takes 1 to 50 values from the queue; comment is optional (up to 500 characters). Each item is approved as the caller, under the "who can decide" rules, and one failure does not stop the others. An approval counts as a success only after the bill is re-read, so each result carries a verified state:
state |
Meaning |
|---|---|
received |
Posted: the bill is received and its receipt journal entry exists |
awaiting_approval |
Moved on to the next step, or a "every approver must approve" step recorded your vote and is waiting for the others |
posting |
Not settled within about 5 seconds; re-check with the verify URL |
failed |
See error (not found or not suspended, not an approver, or the posting failed) |
{
"success": true,
"data": {
"total": 2,
"succeeded": 1,
"posting": 0,
"failed": 1,
"results": [
{
"execution_id": "019ab37c-ae0d-7000-8000-000000000a01",
"success": true,
"state": "received",
"bill_id": "019ab37c-b111-7000-8000-000000000001",
"bill_status": "received",
"journal_entry_id": "019ab37c-c057-7000-8000-000000000c01",
"error": null,
"verify": "GET /api/v1/ap-approval/runs/019ab37c-ae0d-7000-8000-000000000a01"
},
{
"execution_id": "019ab37c-ae0d-7000-8000-000000000a02",
"success": false,
"state": "failed",
"error": "Execution not found or not suspended"
}
]
}
}GET /api/v1/ap-approval/runs/{execution_id} returns the same verified fields for one run (state, execution_status, suspended_at_node, bill_id, bill_status, bill_amount, approval_amount, journal_entry_id, journal_entry_ids, error) and 404 for a run that is not this business's. Posting is refused if the bill's amount no longer matches the amount the run started with, so do not edit a bill's amount while it is pending.
There is no batch reject. To reject a pending bill, or to approve one outside the tiers, use POST /api/v1/bills/{bill_id}/resolve-approval (admin). It cancels the bill's run and then either posts the bill to received (action: "approve", with a ledger_id unless the bill already has a split) or cancels it (action: "reject", optional reason).
Vendor approval routes
A route names who approves one vendor's bills ahead of the amount tiers.
curl -X POST "https://api.ondayzero.com/api/v1/ap-approval/vendor-routes" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"vendor_id": "VENDOR_UUID",
"approver_user_ids": ["USER_UUID_1", "USER_UUID_2"],
"require_all": false,
"ledger_id": "EXPENSE_LEDGER_UUID",
"max_amount_cents": 2500000
}'| Field | Notes |
|---|---|
vendor_id |
Required; the vendor must belong to the business (404 otherwise) |
approver_user_ids |
Required, 1 to 20 users. Each must be an owner, admin or member of the business or staff at its managing firm (400 naming the ineligible ones) |
require_all |
Default false: any one approver decides. true: every approver must approve |
ledger_id |
Only bills coded to this expense ledger. Omit to cover every ledger (400 if the ledger is not the business's) |
max_amount_cents |
Above 0. Bills above it skip the route and use the amount tiers |
active |
Default true; inactive routes are kept but ignored |
Rules:
- One route per vendor and ledger, with "every ledger" counting as its own slot. A duplicate is
409. - A ledger-specific route is tried before the vendor's every-ledger route, and a route whose
max_amount_centsthe bill exceeds is passed over. The ledger is the expense ledger sent with the bill'ssubmit-for-approvalcall. - The approver list is captured when the run starts. Editing or deleting a route does not change who may decide a bill already waiting.
GET /api/v1/ap-approval/vendor-routes (vendor_id, cursor, limit 1 to 200 with a default of 50) returns items, total, has_next and next_cursor, newest first, each with vendor_name and ledger_name resolved. PATCH …/vendor-routes/{route_id} changes any of approver_user_ids, require_all, ledger_id, max_amount_cents and active; send clear_ledger: true or clear_max_amount: true to remove those filters. DELETE …/vendor-routes/{route_id} returns the route as it was. Create, update and delete are admin; list is read.
Endpoint summary
| Method and path | Permission |
|---|---|
GET /ap-approval |
read |
POST /ap-approval/provision |
write |
GET /ap-approval/pending, /completed, /approvers, /thresholds, /analytics, /runs/{execution_id}, /vendor/{vendor_id}/history |
read |
PATCH /ap-approval/thresholds, PUT /ap-approval/approvers/{tier} |
admin |
POST /ap-approval/batch-approve, POST /ap-approval/risk-score |
write |
GET /ap-approval/vendor-routes |
read |
POST /ap-approval/vendor-routes, PATCH / DELETE /ap-approval/vendor-routes/{route_id} |
admin |
All of these sit behind the AP Automation add-on.
Vendor contracts
A vendor contract records what was agreed with a supplier. DayZero compares a bill against the vendor's active contract and stamps the bill's contract_match, which the approval routing above reads.
curl -X POST "https://api.ondayzero.com/api/v1/vendor-contracts" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "Hosting MSA 2026",
"vendor_id": "VENDOR_UUID",
"status": "active",
"effective_date": "2026-01-01",
"expiration_date": "2026-12-31",
"contracted_amount_cents": 125000,
"billing_frequency": "monthly",
"terms_summary": "Net 30, auto-renews annually"
}'name and vendor_id are required. status is draft (the default), active, expired or terminated; it never changes on its own. billing_frequency is monthly, quarterly, annually, one_time, weekly or biweekly. contracted_amount_cents is the agreed amount per billing period. s3_key attaches the contract PDF from an upload (see File Uploads). Responses add id, business_id, vendor_name, created_at and updated_at.
GET /api/v1/vendor-contracts filters by vendor_id and status and takes the standard cursor parameters. GET, PUT and DELETE /api/v1/vendor-contracts/{contract_id} read, update (send only the fields to change) and delete one; deleting a contract does not delete bills. POST /api/v1/vendor-contracts/analyze asks AI to review up to 30 of your contracts and returns insights, each with contract_id, type (renewal, overspend or terms), title, description and severity (info, warning or critical).
Comparing a bill
POST /api/v1/bills/{bill_id}/contract-comparison (write, no body) compares the bill with the vendor's contract, saves the result on the bill and returns bill_id, contract_match and vendor_contract_id.
- Only a contract with status
activeis used; if a vendor has several, the one with the latesteffective_datewins. - Amounts are compared with a 5% tolerance against
contracted_amount_cents.
contract_match |
When |
|---|---|
matched |
Bill amount within 5% of the contract amount |
over_contract |
More than 5% above |
under_contract |
More than 5% below |
needs_review |
The bill or the contract has no amount to compare |
no_contract |
The bill has no vendor, or the vendor has no active contract |
Approval routing runs the comparison itself just before a bill enters approval when the bill has a vendor. Changing a contract does not update bills already compared.
Payment advisory
Payment advisory groups outstanding bills by urgency. Both routes need the AP Automation add-on and read permission.
curl "https://api.ondayzero.com/api/v1/payment-advisory/recommendations" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"{
"success": true,
"data": {
"summary": {
"total_outstanding_cents": 482500,
"total_overdue_cents": 125000,
"total_due_this_week_cents": 60000,
"bills_count": 9
},
"recommendations": {
"overdue": [
{
"bill_id": "019ab37c-b111-7000-8000-000000000001",
"vendor_name": "Acme Hosting",
"amount_cents": 125000,
"balance_cents": 125000,
"due_on": "2026-09-30",
"priority": "overdue",
"action": "pay_now",
"reasoning": "Overdue by 11 days.",
"contract_match": "matched"
}
],
"due_this_week": [],
"due_this_month": [],
"can_defer": []
}
}
}It reads every forecasted, received and partially_paid bill with a balance, ordered by due date, and files each into one band:
priority |
action |
Rule |
|---|---|---|
overdue |
pay_now |
Due date before today. Reasoning adds a contract warning for over_contract |
due_this_week |
pay_soon |
Due within 7 days. Reasoning adds an early-pay discount (percent and deadline) when the bill's payment term has one and the deadline has not passed |
due_this_month |
schedule |
Due within 30 days |
can_defer |
defer |
Due later, or no due date ("No due date set") |
summary.bills_count counts all outstanding bills fetched, including any with a zero balance, which are left out of the bands. The recommendations are rule-based (they do not call AI) and do not look at payment holds.
GET /api/v1/payment-advisory/firm-summary is the advisory-firm roll-up: totals (total_outstanding_cents, total_overdue_cents, bills_needing_review, total_bills) and clients, one row per client business sorted by overdue amount (business_id, business_name, outstanding_cents, overdue_cents, bills_needing_review, total_bills, plus the latest open-period close_score, close_score_color, close_period_id and close_blockers_count). Here draft bills are included in the outstanding figures and counted in bills_needing_review. Firm owners and admins see every client; other roles see only the clients they are assigned.
For a ranked list that also shows cash on hand and early-pay discounts, use the pay plan (GET /api/v1/bills/pay-plan).
Pay lists and bill tie-out
Two bill routes help plan a pay run. They sit under /api/v1/bills, need no add-on, and neither pays anything. Both are POST and need write permission, even though they only read.
Build a pay list
curl -X POST "https://api.ondayzero.com/api/v1/bills/payment-run" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"pay_through": "2026-10-16",
"cash_available_cents": 900000,
"rows": [
{"vendor_name": "Acme Hosting", "invoice_number": "INV-1042", "amount_cents": 125000, "status": "Approved", "due_on": "2026-10-12"}
]
}'pay_through (required) is the last due date to include. cash_available_cents caps spending and defaults to the book balance of the business's bank accounts (credit cards excluded); the response says which in cash_source (given or bank_ledger_book_balances). rows or csv_text are optional and describe an external bill-pay system's approved list (see the next section for their format); when you send them, only bills that system has approved can be paid.
Open bills (received, partially_paid) are sorted by due date, then amount, and each lands in one bucket:
| Bucket | reason |
Why |
|---|---|---|
deferred |
due_later |
No due date, or due after pay_through |
blocked |
on_hold |
The bill has a payment hold |
blocked |
missing_externally |
Rows were sent and none matches the bill |
blocked |
paid_externally |
The matching row's status says paid or in flight |
blocked |
not_approved_externally |
The matching row's status is not an approved or open one |
blocked |
amount_mismatch |
The row's amount differs from the bill's balance (delta_cents shows by how much) |
blocked |
payment_initiated |
The bill is flagged as already sent for payment (PATCH /bills/{id}/payment-initiation) |
deferred |
insufficient_cash |
The balance does not fit in the cash left |
pay |
none | Everything else; pay_cents is the full balance |
Two further blocks apply when rows are sent: a bill already paid here that the external system still shows as open is double_payment_risk, and a pending_approval bill the external system shows as open is pending_approval. Cash is spent in due-date order, and a bill that does not fit is deferred without stopping smaller, later bills from fitting.
{
"success": true,
"data": {
"pay_through": "2026-10-16",
"cash_available_cents": 900000,
"cash_remaining_cents": 775000,
"cash_source": "given",
"pay": [
{
"bill_id": "019ab37c-b111-7000-8000-000000000001",
"vendor_name": "Acme Hosting",
"bill_number": "INV-1042",
"status": "received",
"amount_cents": 125000,
"balance_cents": 125000,
"due_on": "2026-10-12",
"pay_cents": 125000,
"external_row": 1,
"external_status": "Approved"
}
],
"blocked": [],
"deferred": [],
"totals": {
"pay": {"count": 1, "cents": 125000},
"blocked": {"count": 0, "cents": 0},
"deferred": {"count": 0, "cents": 0}
}
}
}Each blocked and deferred item carries the bill fields plus reason; blocked items add external_row, external_status and external_amount_cents.
Tie out against an external bill-pay export
Businesses that pay through a system with no API can compare its export with their books:
curl -X POST "https://api.ondayzero.com/api/v1/bills/tie-out" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"csv_text": "Vendor,Invoice #,Amount,Status,Due date\nAcme Hosting,INV-1042,1250.00,Approved,10/12/2026\n", "as_of": "2026-10-11"}'Send either rows or csv_text, never both (400), with at most 2,000 rows. Row fields:
| Field | Notes |
|---|---|
invoice_number |
Required; matched to bill_number after trimming punctuation and case |
amount_cents |
Required, integer cents |
vendor_name, status, approver |
Optional text |
due_on |
Optional date |
In csv_text the first row is the header and amounts are dollars ($ and commas are tolerated, parentheses mean negative). Columns are recognized by name: vendor, payee, supplier, merchant or recipient; invoice, bill number, bill no, bill #, reference or number; amount, total or approved amount; status or approval; due; approver or assigned. An invoice and an amount column are required. as_of is the date the 7-day bank-feed window ends on (default today).
An external status is read as paid (paid, scheduled, processing, sent or settled, unless it says unpaid), open (approved, unpaid, open, overdue or due) or not approved (anything else). The response has:
| Key | Contents |
|---|---|
forward |
Each received or partially_paid bill with verdict matched or missing_externally, and delta_cents against the matched row |
reverse |
Each external row with a verdict: ok, ok_if_amount_equals_balance, amount_differs_from_balance, pull_from_batch (bill still pending_approval), invisible_to_aging (bill is draft or forecasted), double_payment_risk (bill already paid) or unknown_in_books |
duplicates |
bill_numbers and vendors that look duplicated in the books |
paid_here_open_there |
Bills paid in DayZero that the export still shows as not paid |
settlement_in_flight |
Bills the export calls paid with no payment recorded here. likely_settled_txn_id is a bank transaction in the 7 days to as_of with the balance as its amount that mentions the bill number or the vendor's first word |
totals |
Counts and cents for each bucket |
Pay plan
GET /api/v1/bills/pay-plan (read permission, no body) ranks the open bills by what to pay first and shows cash on hand. It is deterministic and does not call AI. POST /api/v1/bills/suggest-payment-priority returns the same response and is kept for older clients. Details, bands and the response are in Bills. To match bank transactions to bills automatically, see Payment Suggestions.
Vendor credits
A vendor credit is money a vendor owes back (a return, overpayment or rebate) that you apply against that vendor's open bills. It is the payables counterpart of Credit Memos.
draft → issued → partially_applied → applied
↘ ↘
void void| Status | Description |
|---|---|
draft |
Editable; nothing posted |
issued |
Posted and available to apply |
partially_applied |
Some of the credit is applied |
applied |
Fully used |
void |
Cancelled; an issued credit's entry is reversed |
Create and issue
curl -X POST "https://api.ondayzero.com/api/v1/vendor-credits" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"vendor_id": "VENDOR_UUID",
"amount_cents": 5000,
"reason": "pricing_error",
"description": "Overcharged on bill BILL-001",
"bill_id": "ORIGINAL_BILL_UUID",
"ledger_id": "OFFSET_LEDGER_UUID",
"vendor_credit_number": "CM-9087"
}'vendor_id and amount_cents (above 0) are required. Optional fields:
| Field | Notes |
|---|---|
reason |
returned_goods, damaged_goods, service_issue, pricing_error, billing_adjustment, rebate, duplicate_charge or other (default) |
bill_id |
The bill being credited; it must belong to the same vendor |
ledger_id |
Offset account, any chart-of-accounts type; defaults to Uncategorized Expense |
allocation_lines |
Split the credit across accounts: [{"ledger_id", "amount", "description"}] in cents. The amounts must add up to amount_cents, and ledger_id is then ignored |
issue_date |
Defaults to today; used as the entry date on issue |
line_items |
Detail only: description, quantity, unit_price_cents, amount_cents |
internal_notes, description |
Free text |
vendor_credit_number |
The vendor's own reference, up to 100 characters |
The credit is created as a draft with an automatic number such as VC-0001. PUT /api/v1/vendor-credits/{vendor_credit_id} updates a draft (same fields, all optional, 400 once issued). POST …/{vendor_credit_id}/issue posts the entry (debit Vendor Credit Balance, credit the offset account or accounts) and makes the credit available.
Apply, un-apply, void, delete
curl -X POST "https://api.ondayzero.com/api/v1/vendor-credits/{vendor_credit_id}/apply" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"bill_id": "BILL_UUID", "amount_cents": 2500}'The credit must be issued or partially_applied with a remaining balance. The bill must belong to the same vendor and not be paid or canceled, and amount_cents cannot exceed the credit's remaining amount or the bill's balance due. Applying posts an entry (debit Accounts Payable, credit Vendor Credit Balance, dated today) and lowers the bill's balance without a bank transaction: the bill becomes paid when payments plus credits cover it, otherwise partially_paid. The response is the application record (id, vendor_credit_id, bill_id, amount_applied, applied_at, bill_status, bill_balance_due).
POST …/{vendor_credit_id}/unapplywith{"bill_id": "…"}removes all of that credit's applications to that bill, restores the balances and removes the apply entry. It returnsamount_unapplied,applications_removed,vendor_credit_status,amount_remainingandbill_status.POST …/{vendor_credit_id}/voidwith{"reason": "…"}works ondraftandissuedcredits only (not once any amount is applied) and posts a reversing entry for an issued one.DELETE …/{vendor_credit_id}removes any credit except avoidone; applications are reversed and the affected bills recalculated.POST …/{vendor_credit_id}/journal-entry(optionalledger_id,journal_entry_date) posts the issue entry for a credit that has none;POST …/journal-entry/linkwith{"journal_entry_id": "…"}links an existing entry instead. Both are409if the credit already has an entry, and the first is400on a void credit.
Read
GET /api/v1/vendor-credits filters by vendor_id, status, has_remaining_balance=true and search (the VC- number), with cursor and limit (1 to 100, default 50). GET /api/v1/vendor-credits/{vendor_credit_id} includes bill_applications and journal_entries. GET /api/v1/vendor-credits/vendors/{vendor_id}/credits returns what a vendor has available: total_credits_available (cents), vendor_credits_count and the credits themselves.
A credit response carries both cent and dollar amounts (amount, amount_applied, amount_remaining, with matching …_in_dollars fields), plus status, reason, reason_display, can_be_applied, can_be_voided, can_be_deleted and has_applications; use the cent fields.
Vendor bill-request links
A bill-request link lets a vendor or contractor without a DayZero account submit an invoice. Managers mint links (authenticated); vendors use them (no token needed).
Manage links
curl -X POST "https://api.ondayzero.com/api/v1/vendor-bill-requests" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"vendor_id": "VENDOR_UUID", "label": "Q4 contractors", "expires_in_days": 30}'All fields are optional: vendor_id binds the link to one vendor (404 if it is not the business's), label (up to 120 characters) is shown on the form heading, and expires_in_days (1 to 365) sets an expiry, otherwise the link does not expire. The response is {"link": {…}, "url": "https://…/submit-bill/{token}"}. url is returned once: only a hash of the token is stored, so it cannot be recovered. The link has id, vendor_id, vendor_name, label, expires_at, revoked_at, is_active, submission_count and last_submitted_at.
GET /api/v1/vendor-bill-requests lists every link, active and revoked, newest first, without tokens (one items array, not paginated). DELETE /api/v1/vendor-bill-requests/{link_id} revokes a link; repeating it is harmless.
The public submission endpoints
The vendor's page at /submit-bill/{token} uses two unauthenticated routes, which you can also call directly. The token in the URL is the only credential.
curl "https://api.ondayzero.com/api/v1/public/bill-requests/TOKEN"returns business_name, vendor_name (when pre-bound), label, fields_required, accepted_file_types (pdf, png, jpg, jpeg) and max_file_size_mb (10).
curl -X POST "https://api.ondayzero.com/api/v1/public/bill-requests/TOKEN" \
-F submitter_name="Jordan Lee Design" \
-F submitter_email="jordan@example.com" \
-F amount="1250.00" \
-F invoice_number="INV-77" \
-F due_date="2026-11-15" \
-F description="October brand work" \
-F file=@invoice.pdf| Field | Notes |
|---|---|
submitter_name, submitter_email, amount, file |
Required |
amount |
Dollars as a string ("1,250.00" and "$1250" are accepted): above 0, at most two decimals, at most $10,000,000 |
invoice_number |
Up to 100 characters |
due_date |
YYYY-MM-DD |
description |
Up to 2,000 characters |
The response is {"bill_reference": "INV-77", "message": "…"}. What happens to the submission:
- It becomes a
draftbill (neverpending_approval) withexpected_amountset to the amount, typeone_time, the file attached and sourcevendor_portal. Nothing is posted; the manager is notified and confirms (mark received) or denies (cancel) it from the Bill Inbox. - The vendor is the pre-bound one if the link has it; otherwise an existing vendor with the same email, then the same normalized name; otherwise a new vendor is created from the name and email.
- A possible duplicate (same invoice number, or same vendor and amount) is accepted and flagged, not rejected: the description is prefixed with
[Possible duplicate]and the existing bill's number. If the vendor's invoice number is already used, the bill is auto-numbered and the vendor's number is kept in that note. - Invalid fields are
400with the field named inerrors; a bad file type is400; an oversized body is413. - Unknown, expired and revoked tokens all return the same
404. Rate limits return429: 60 reads per minute per IP, and 10 submissions per minute per IP plus 30 per hour per link.
Contractors and 1099
Contractors are vendors with the 1099 flag. These routes edit the flag and tax details and total a year's payments. For the vendor record itself, see Vendors.
curl -X PUT "https://api.ondayzero.com/api/v1/contractors/{vendor_id}" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"is_1099_contractor": true, "tax_id": "12-3456789", "tax_id_type": "ein", "default_1099_box": "NEC-1"}'All four fields are optional and only those sent change. tax_id_type is ein or ssn, tax_id is up to 50 characters and default_1099_box up to 20 (for example NEC-1); the box is a label, DayZero does not file forms. An unknown vendor is 404.
GET /api/v1/contractors lists the business's vendors (not deleted ones) ordered by name as items with id, name, email, tax_id, is_1099_contractor, tax_id_type and default_1099_box. Add only_1099=true for flagged vendors only. The list is not paginated.
curl "https://api.ondayzero.com/api/v1/contractors/1099-report?year=2026" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"year is required (2000 to 2100). The report sums the bill payments recorded for each flagged vendor whose payment date falls in that calendar year, so a December bill paid in January counts toward the new year. Vendor credit applications and payments that were not recorded against a bill are not counted.
{
"success": true,
"data": {
"year": 2026,
"threshold_cents": 60000,
"rows": [
{
"vendor_id": "019ab37c-ae0d-7000-8000-000000000001",
"name": "Jordan Lee Design",
"tax_id": "12-3456789",
"tax_id_type": "ein",
"box": "NEC-1",
"total_paid": 312500,
"meets_threshold": true
}
],
"total_paid": 312500,
"reportable_count": 1
}
}Rows are sorted by total_paid, highest first, and meets_threshold is true at $600 (60000 cents) or more. total_paid at the top is the sum of every flagged contractor, including those under the threshold.
Purchase orders
Purchase orders live at /api/v1/purchase-orders and need the Commerce & Inventory add-on. Creating, listing, the status lifecycle and receiving are covered in Inventory; the statuses are draft, outstanding, submitted, in_production, ready, shipped, partially_received, received, completed and canceled.
How they connect to accounts payable:
POST …/{id}/submitmoves the order tosubmittedand creates aforecastedbill for it.POST …/{id}/receivemoves it toreceivedand flips that bill fromforecastedtoreceived, which posts the payable.POST …/{id}/cancelcancels the order and reverses any in-transit transfer.POST …/{id}/paymentslinks a bank transaction to the order:{"transaction_id": "…", "amount": 250000, "paid_on": "2026-10-28T00:00:00"}.amountis cents above 0,paid_onis optional (default: the transaction's date), and several partial payments are allowed. It returns the payment (id,inventory_order_id,amount,paid_on,transaction_id,method).
Other routes: GET /api/v1/purchase-orders/{id} fetches one with its line items; PUT /api/v1/purchase-orders/{id} updates it (every field optional) and accepts version for optimistic locking, answering 409 on a mismatch; POST …/{id}/recalculate-totals recomputes totals from line items and payments; POST …/{id}/pdf generates a PDF and returns its url, and GET /api/v1/purchase-orders/pdf?order_ids=ID1,ID2 returns download URLs for several. DELETE …/{id} is only allowed for a draft or submitted order with no linked receipts, bills or journal entries (400 or 409 otherwise); cancel anything further along.
Expense reports and purchase requisitions
Both modules are parked: the app, Regent and the MCP server do not offer them. Their REST routes keep working for data that already exists. Neither needs an add-on and both use the default permissions.
Expense reports (/api/v1/expense-reports) hold employee spend and move draft → submitted → approved → reimbursed. Approving accrues the report to the books (debit each line's expense account, credit Reimbursements Payable, created on first use) and reimbursing posts the payment (debit Reimbursements Payable, credit the bank or cash account you name). Submitting posts nothing. Amounts are in cents.
POSTcreates a draft withtitle,description,currency(defaultUSD) andlines(at least one):description,amount(cents, above 0),expense_date, optionals3_key(receipt) andledger_id(expense account).PUT /{report_id}edits a draft (replacing the lines when you send them) andDELETE /{report_id}removes one; both are400once the report is submitted.POST /{report_id}/submittakes no body.POST /{report_id}/approvetakes an optional{"line_ledgers": [{"line_id": "…", "ledger_id": "…"}]}to set or change expense accounts; every line needs one by approval, and the approval date must fall in an open period. The submitter cannot approve their own report, and approve, reject and reimburse are limited to business owners, admins and the managing advisory firm's staff.POST /{report_id}/rejecttakes{"reason": "Hotel receipt missing"}(1–1000 characters) and sends a submitted or approved report back todraft; a reimbursed report cannot be rejected. Rejecting an approved report reverses its accrual entry. The reversal is dated today; an accrual in a closed period is left untouched and the reversal's description names it.400VALIDATION_897when today is in a closed period too.POST /{report_id}/reimbursetakes{"bank_ledger_id": "…", "payment_date": "2026-10-14", "payment_reference": "ACH 4471"}; onlybank_ledger_id(an active bank or cash account) is required. It records a payment you made elsewhere and does not move money. Optionalbank_transaction_id(a payment out ofbank_ledger_idfor exactly the report total, uncategorized or on Reimbursements Payable, not linked elsewhere) books the payment through that transaction, dated when it cleared, and locks its category (VALIDATION_898if you later try to recategorize it). A mismatch is400VALIDATION_895, one already in use is400VALIDATION_896, and another business's is404.GET /{report_id}/reimbursement-candidates?bank_ledger_id=…lists the eligible ones (itemswithid,transaction_date,description,counterparty,amount). Withoutbank_transaction_idthe bank is credited directly andwarningsexplains the later match. The report detail returnsapproval_journal_entry_id,reimbursement_journal_entry_idandreimbursement_transaction_id(null when posted straight to the bank account) once they exist.- Calling a step from the wrong status is
400. GETlists withstatusandsearch(title, description or number), cursor-paginated.
Purchase requisitions (/api/v1/purchase-requisitions) capture intended spend before a purchase order or bill exists: draft → submitted → approved → converted, or rejected from submitted.
POSTcreates a draft withdescription,vendor_id,currencyandlines(at least one;description,amountin cents, optionalledger_id).PUTandDELETEwork on drafts only.POST /{requisition_id}/submitalso starts an approval run if the business or its firm has a published workflow;/approveand/reject({"reason": "…"}, required) change the status directly.GETlists withstatus,vendor_idandsearch.POST /{requisition_id}/convertwith{"target": "purchase_order"}or{"target": "bill"}works on approved requisitions only. A purchase order target needs avendor_idon the requisition and creates a draft PO numbered<requisition number>-PO; a bill target creates a one-time draft bill with the requisition total as its expected amount. The response returns the requisition plusinventory_order_idorbill_id.
Errors
| Status | Typical cause |
|---|---|
400 |
Business-rule violations: a status that does not allow the action (voiding an applied vendor credit, editing a non-draft), ops_approve_max_cents not above auto_approve_max_cents, ineligible approvers, rows and csv_text together |
403 |
Add-on not enabled (BILLING_003), missing permission, or a user who is not an approver for the step |
404 |
Unknown id, or one that belongs to another business; unknown bill-request token |
409 |
Duplicate vendor approval route, a journal entry already linked to a vendor credit, a purchase order version conflict |
413, 429 |
Public bill-request submission too large or rate-limited |