Skip to content

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:

  1. 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/provision fails with 404 for a business with no managing firm.
  2. The business switch is on. PUT /api/v1/businesses/{business_id} (admin) with {"ap_approval_enabled": true}; business_id must be the business in x-business-id. It is off by default, and while off, bills skip approval entirely.
  3. 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.

  1. 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 is over_contract or needs_review goes on to the advisor step and then the owner step; any other bill posts.
  2. 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 client and viewer roles cannot decide.
  • A person refused gets 403 with 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

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

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

bash
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

bash
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"
json
{
  "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"
        }
      }
    ]
  }
}
  • tier filters to ops, advisor or owner (vendor-route items show tier vendor). mine=true keeps 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: null and needs_routing: true is a pending_approval bill 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 with execution_id, tier, action (approve, reject, or modify for Approve with Notes), comment, completed_at and bill. 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) returns vendor_name, recent_bills (id, amount, status, description, bill_number, created_at), total_paid_cents (over the bills returned) and bill_count, for context at decision time.
  • POST /api/v1/ap-approval/risk-score asks AI to score up to 30 recent draft and received bills. Each item has bill_id, vendor_name, risk_score (0 to 100, higher is riskier) and up to three risk_factors. amount on these items is the model's echo in dollars, not cents; do not use it for arithmetic.

Approve and reject

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

bash
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_cents the bill exceeds is passed over. The ledger is the expense ledger sent with the bill's submit-for-approval call.
  • 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.

bash
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 active is used; if a vendor has several, the one with the latest effective_date wins.
  • 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.

bash
curl "https://api.ondayzero.com/api/v1/payment-advisory/recommendations" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"
json
{
  "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

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

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

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

text
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

bash
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

bash
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}/unapply with {"bill_id": "…"} removes all of that credit's applications to that bill, restores the balances and removes the apply entry. It returns amount_unapplied, applications_removed, vendor_credit_status, amount_remaining and bill_status.
  • POST …/{vendor_credit_id}/void with {"reason": "…"} works on draft and issued credits only (not once any amount is applied) and posts a reversing entry for an issued one.
  • DELETE …/{vendor_credit_id} removes any credit except a void one; applications are reversed and the affected bills recalculated.
  • POST …/{vendor_credit_id}/journal-entry (optional ledger_id, journal_entry_date) posts the issue entry for a credit that has none; POST …/journal-entry/link with {"journal_entry_id": "…"} links an existing entry instead. Both are 409 if the credit already has an entry, and the first is 400 on 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

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

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

bash
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 draft bill (never pending_approval) with expected_amount set to the amount, type one_time, the file attached and source vendor_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 400 with the field named in errors; a bad file type is 400; an oversized body is 413.
  • Unknown, expired and revoked tokens all return the same 404. Rate limits return 429: 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.

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

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

json
{
  "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}/submit moves the order to submitted and creates a forecasted bill for it.
  • POST …/{id}/receive moves it to received and flips that bill from forecasted to received, which posts the payable.
  • POST …/{id}/cancel cancels the order and reverses any in-transit transfer.
  • POST …/{id}/payments links a bank transaction to the order: {"transaction_id": "…", "amount": 250000, "paid_on": "2026-10-28T00:00:00"}. amount is cents above 0, paid_on is 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.

  • POST creates a draft with title, description, currency (default USD) and lines (at least one): description, amount (cents, above 0), expense_date, optional s3_key (receipt) and ledger_id (expense account). PUT /{report_id} edits a draft (replacing the lines when you send them) and DELETE /{report_id} removes one; both are 400 once the report is submitted.
  • POST /{report_id}/submit takes no body. POST /{report_id}/approve takes 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}/reject takes {"reason": "Hotel receipt missing"} (1–1000 characters) and sends a submitted or approved report back to draft; 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. 400 VALIDATION_897 when today is in a closed period too.
  • POST /{report_id}/reimburse takes {"bank_ledger_id": "…", "payment_date": "2026-10-14", "payment_reference": "ACH 4471"}; only bank_ledger_id (an active bank or cash account) is required. It records a payment you made elsewhere and does not move money. Optional bank_transaction_id (a payment out of bank_ledger_id for 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_898 if you later try to recategorize it). A mismatch is 400 VALIDATION_895, one already in use is 400 VALIDATION_896, and another business's is 404. GET /{report_id}/reimbursement-candidates?bank_ledger_id=… lists the eligible ones (items with id, transaction_date, description, counterparty, amount). Without bank_transaction_id the bank is credited directly and warnings explains the later match. The report detail returns approval_journal_entry_id, reimbursement_journal_entry_id and reimbursement_transaction_id (null when posted straight to the bank account) once they exist.
  • Calling a step from the wrong status is 400.
  • GET lists with status and search (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.

  • POST creates a draft with description, vendor_id, currency and lines (at least one; description, amount in cents, optional ledger_id). PUT and DELETE work on drafts only.
  • POST /{requisition_id}/submit also starts an approval run if the business or its firm has a published workflow; /approve and /reject ({"reason": "…"}, required) change the status directly. GET lists with status, vendor_id and search.
  • POST /{requisition_id}/convert with {"target": "purchase_order"} or {"target": "bill"} works on approved requisitions only. A purchase order target needs a vendor_id on 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 plus inventory_order_id or bill_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