Order to Cash
Order to Cash (O2C) follows a sale from first contact to recognized revenue. Each stage has its own record:
deal (pipeline) → order (draft → sent → signed → countersigned) → contract → invoices, drawdowns, change orders → revenue schedules
/o2c/deals /orders /contracts /change-orders /revenue-schedules, /deferred-revenue- A deal is a pipeline card. Converting a won deal creates a customer (if needed), a project and a draft order.
- An order is the order form: one customer, optionally one project, and lines that each carry a billing rule.
- Countersigning an order freezes it and creates a live contract. The contract bills, draws down hours and tracks billed vs. recognized revenue.
- A change order amends a live contract's value and hours after signing.
- Revenue schedules and deferred revenue show what is billed but not yet earned and when it releases.
This guide covers the main workflows of those route families. See What is not covered for the rest. Money is integer cents unless a field says otherwise, and hours are decimals.
Conventions
Every request needs Authorization: Bearer dz_your_token_here and x-business-id: YOUR_BUSINESS_ID (see Authentication).
| Method | Permission needed |
|---|---|
GET |
read |
POST, PUT, PATCH |
write |
DELETE |
delete |
For a business's own users, viewer has read, member has read and write, and admin and owner also have delete. A read_only token can only call GET. These routes do not require an add-on. Like other write routes, writes are refused with 403 (BILLING_001 or BILLING_002) when the business's trial has ended and it has no active subscription; reads still work.
Successful responses use the standard envelope, {"success": true, "data": ...}. Deletes return {"success": true, "message": "...", "code": "..."}. Errors use the shape in Error Handling. Send an Idempotency-Key header on POST, PUT and PATCH calls you might retry.
Lists use the cursor shape from Pagination (items, next_cursor, prev_cursor, has_next, has_prev, limit, total), except revenue schedules and deferred revenue, which use limit and offset.
| Route family | Default limit |
Maximum | Notes |
|---|---|---|---|
/orders, /contracts, /change-orders, /contracts/drawdowns, /contracts/invoices |
50 | 100 | Forward only (cursor from next_cursor). total is null. Orders, contracts, change orders and drawdowns list newest first |
/o2c/deals, /o2c/deals/pipeline |
50 | 100 | total is always filled. sort_by and descending (default true) are accepted. /o2c/deals also takes direction=prev |
/revenue-schedules, /deferred-revenue, /deferred-revenue/recognized |
100 | 500 | offset paging |
Deals
A deal tracks a prospective sale. Early deals use a free-text prospect_name; a deal for an existing customer uses customer_id.
| Stage | Meaning |
|---|---|
prospecting |
Default for a new deal |
qualified, proposal, negotiation |
Other open stages. An open deal can move to any other stage, forward or backward |
closed_won, closed_lost |
Closed. A closed deal cannot change stage through PATCH |
Other enums:
source:referral,outbound,inbound,partner,event,expansion,other.revenue_type:recurring,one_time.- Lost
reason:price,competitor,timing,no_budget,no_decision,poor_fit,other.
Create a deal
curl -X POST "https://api.ondayzero.com/api/v1/o2c/deals" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"prospect_name": "Acme Solar",
"contact_name": "Jane Rivera",
"contact_email": "jane@acmesolar.example",
"source": "referral",
"owner_name": "Bryce",
"deal_type": "EPC",
"revenue_type": "recurring",
"expected_value_cents": 4800000,
"expected_hours": 320,
"expected_term_months": 12,
"expected_start_date": "2026-12-01",
"expected_close_date": "2026-11-15"
}'Send prospect_name or customer_id; with neither you get 400. stage defaults to prospecting, name defaults to the prospect or customer name, and is_expansion (default false) marks expansion revenue on an existing customer. expected_value_cents defaults to 0 and cannot be negative. owner_name (up to 255 characters) and deal_type (up to 60) are free text. Dates are YYYY-MM-DD.
The response is a deal with id, stage, effective_stage, customer_id, customer_name, project_id, project_name, order_id, the fields above, lost_reason, notes, stage_changed_at, days_in_stage, is_stalled, and the flags is_open, is_closed, is_won, is_lost and is_converted.
effective_stage is closed_won once the deal has an order_id, whatever stage says. is_stalled is true when an open deal has sat in its stage for at least 14 days (prospecting, proposal), 21 days (qualified) or 30 days (negotiation).
List, search and view the pipeline
curl "https://api.ondayzero.com/api/v1/o2c/deals?stage=proposal&is_expansion=false&owner=Bryce&search=acme&limit=50" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"| Query parameter | Meaning |
|---|---|
stage |
Exact match on the raw stage (not effective_stage) |
is_expansion |
false for new customers, true for expansion |
owner |
Case-insensitive exact match on owner_name. Use __unassigned__ for deals with no owner |
search |
Case-insensitive substring match on deal name, prospect name or contact name |
cursor, limit, direction, sort_by, descending |
Pagination and sorting |
GET /api/v1/o2c/deals/ownersreturnsowners(owner_name,deal_count, busiest first) andunassigned_count.GET /api/v1/o2c/deals/pipelinereturnsmetricsplus adealspage. It acceptsstage,is_expansion,cursor,limit,sort_byanddescending.metricsalways covers every open deal, whatever the filters. Open means an open stage and noorder_id.GET /api/v1/o2c/deals/{deal_id}returns one deal.
metrics field |
Meaning |
|---|---|
total_pipeline_cents |
Sum of expected_value_cents over open deals |
weighted_pipeline_cents |
The same sum weighted by stage: prospecting 10%, qualified 25%, proposal 50%, negotiation 75% |
open_deal_count |
Number of open deals |
closing_this_quarter_cents |
Open deals whose expected_close_date falls in the current calendar quarter |
new_customer_cents, expansion_cents |
Open value split by is_expansion |
Edit, lose and reopen
PATCH /api/v1/o2c/deals/{deal_id} accepts stage, notes, owner_name, deal_type, revenue_type and the expected_* fields. Omitted fields are untouched. The nullable fields (notes, owner_name, deal_type, revenue_type, expected_hours, expected_term_months, expected_start_date, expected_close_date) are cleared by sending null. expected_value_cents cannot be cleared; a null is ignored. The call is refused with 400 (VALIDATION_450) once the deal has an order_id. Manage the order instead.
curl -X POST "https://api.ondayzero.com/api/v1/o2c/deals/DEAL_UUID/mark-lost" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"reason": "price", "note": "Went with a cheaper bid"}'mark-lost requires reason, closes an open deal as closed_lost and appends [Lost: price] <note> to notes. It returns 400 if the deal is already closed. Use it rather than PATCH with stage: closed_lost, because PATCH does not record a lost_reason.
POST /api/v1/o2c/deals/{deal_id}/reopen takes no body. It restores a closed_lost deal to the stage it had before it was marked lost (prospecting if unknown) and clears lost_reason. A deal that is not closed_lost gets 400. A deal that has converted to an order gets 409.
Bulk create
POST /api/v1/o2c/deals/bulk takes {"deals": [...]} with 1 to 250 rows, each shaped like a create body. Rows are independent: a bad row fails alone. An unknown stage, source or revenue_type fails only that row. The response has created, failed and results, where each result has the 1-based row, success, deal_id, name and error.
Convert a deal to an order
curl -X POST "https://api.ondayzero.com/api/v1/o2c/deals/DEAL_UUID/create-order" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"project_name": "Acme Solar - Phase 1", "order_description": "Phase 1 services"}'project_name is required (1 to 255 characters). order_description defaults to the deal name. In one atomic step the call:
- Uses the deal's customer, or creates one from
prospect_name(falling back to the deal name), withcontact_emailas the customer email. - Creates an active project under that customer. The name must be unique among the customer's projects, ignoring case, or you get
400. - Creates a draft order with the next
ORD-NNNNnumber.order_start_dateandterm_monthscome from the deal'sexpected_start_dateandexpected_term_months. - Adds one
flatline described byorder_description. Withexpected_hours, the line isqty = hoursatunit_price_cents = value / hours(rounded to 2 decimals of a cent). Otherwise it isqty = 1at the fullexpected_value_cents. - Stamps the deal with
customer_id,project_idandorder_id, and records a stage event toclosed_won.
The deal's own stage column is not changed; read effective_stage or is_converted. The response has customer_id, project_id, order_id and the refreshed deal. A second call returns 400. The new order is a normal draft: edit it, then send, sign and countersign it as below.
Orders
An order is an order form. Statuses:
| Status | Meaning | Next steps |
|---|---|---|
draft |
Editable | send, mark-sent, esign/send, delete |
sent |
Out for signature | sign, esign/send, attach-contract-file, remind-signer, revert-to-draft, delete |
signed |
Customer has signed | countersign, attach-contract-file, remind-signer, revert-to-draft, delete |
countersigned |
Frozen (frozen: true) and a contract exists |
Delete only after its contract is deleted |
The response carries can_be_sent, can_be_signed, can_be_countersigned, can_be_updated, can_be_reverted and can_be_deleted, so you can check which actions are open without trying them. A call in the wrong status returns 400 with a VALIDATION_* code, for example VALIDATION_359 ("Only draft orders can be sent for signature").
Create an order
curl -X POST "https://api.ondayzero.com/api/v1/orders" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "019ab37c-c057-7000-8000-000000000001",
"project_id": "019ab37c-c057-7000-8000-000000000002",
"order_description": "Implementation and platform subscription",
"order_start_date": "2026-11-01",
"first_invoice_date": "2026-11-01",
"term_months": 12,
"discount_cents": 50000,
"currency": "USD",
"lines": [
{
"description": "Prepaid implementation hours",
"qty": 100,
"unit_price_cents": 15000,
"billing_rule": "prepaid_block"
},
{
"description": "Platform subscription",
"qty": 1,
"unit_price_cents": 200000,
"billing_rule": "recurring",
"billing_timing": "each_period"
}
]
}'Only customer_id is required. The order is created as draft with the next ORD-NNNN number. The customer must exist in the business and not be deleted, and project_id must belong to the business (404 otherwise).
| Field | Notes |
|---|---|
order_start_date, first_invoice_date |
YYYY-MM-DD. first_invoice_date anchors the term clock; end_date is that date plus term_months (falling back to order_start_date) |
term_months |
Integer, at least 1 |
discount_cents |
Default 0. A discount above the subtotal is 400 |
tax_treatment |
Free-text label |
currency |
3-letter code, upper-cased. Default USD |
lines |
Order lines (below). Can be empty on a draft, but an order needs at least one line before it can be sent |
Each line has qty (decimal, up to 2 decimal places, at least 0), unit_price_cents (at least 0, up to 2 decimal places so a rate like 5.25 means $0.0525), description, catalog_item_id, billing_rule (default flat), billing_timing and ramp_schedule (a JSON object for pre-agreed step quantities or prices).
billing_rule |
How the contract bills it (from the contract billing code) |
|---|---|
flat |
One amount on the contract's first (execution) invoice |
prepaid_block |
On the execution invoice, booked to Deferred Revenue and recognized as approved hours burn down. Its qty counts toward the contract's contracted hours |
recurring |
billing_timing is each_period (billed on each period invoice), first_upfront or all_upfront (the latter two bill on the execution invoice) |
per_unit |
Billed on period invoices from approved drawdown hours times unit_price_cents, capped at qty. If an order has no upfront lines, the execution invoice instead bills the bought quantity and sending it books it to Deferred Revenue |
usage_metered |
Accepted and stored. The contract draft endpoints do not draft it automatically; bill it with a manual contract invoice |
Each line in the response also has amount_cents (qty times unit_price_cents, rounded half-up to whole cents) and catalog_item_name. The order's subtotal_cents is the sum of line amounts and is not multiplied by term_months; order_value_cents is the subtotal minus discount_cents, never below 0.
List, get and update
curl "https://api.ondayzero.com/api/v1/orders?status=sent&customer_id=CUSTOMER_UUID&limit=50" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"status and customer_id are the only filters. status is matched exactly against draft, sent, signed or countersigned; an unknown value returns an empty page. GET /api/v1/orders/{order_id} returns the order with its lines, contract_id (once countersigned), customer_name, project_name and project_code.
PUT /api/v1/orders/{order_id} edits a draft only (400, VALIDATION_358 otherwise; revert a sent or signed order first). It takes the same fields as create, all optional. Omitted fields are untouched. project_id, the dates, term_months, order_description and tax_treatment are cleared with null. If you send lines, they replace all existing lines, and the subtotal is recomputed. The discount is re-checked against the subtotal.
Send, sign and countersign
| Endpoint | Body | Effect |
|---|---|---|
POST /orders/{id}/send |
none | draft to sent; sets sent_at |
POST /orders/{id}/mark-sent |
none | The same transition |
POST /orders/{id}/sign |
optional {"signed_contract_file_url": "..."} |
sent to signed; sets signed_at |
POST /orders/{id}/attach-contract-file |
{"signed_contract_file_url": "..."} |
Stores the executed contract reference on a sent, signed or countersigned order. On a sent order it also moves the order to signed |
POST /orders/{id}/countersign |
optional {"signed_contract_file_url": "..."} |
signed to countersigned; freezes the order and creates the contract |
POST /orders/{id}/revert-to-draft |
none | sent or signed back to draft; clears sent_at, signed_at and signed_contract_file_url |
POST /orders/{id}/remind-signer |
none | Emails the customer a signature reminder |
DELETE /orders/{id} |
none | Removes a draft, sent or signed order, or a countersigned one whose contract was deleted |
What "send" actually does. send and mark-sent are status changes only. Both require the order to be draft and to have at least one line, then set status to sent and sent_at. Neither emails the customer, creates a document or contacts a signature provider. The code notes that send is where a customer notification would go in future, and that mark-sent must never notify. Today the two behave identically. To email the customer a signing request, use the e-signature endpoint below.
Signing is recorded, not collected. sign does not capture a signature. You call it, with an optional reference to the executed PDF, once the customer has signed outside DayZero. The e-signature webhook calls it for you when an envelope completes. To register an executed PDF, upload it (see Uploads), then pass the returned s3_key or a URL as signed_contract_file_url to sign, attach-contract-file or countersign.
Remind the signer. remind-signer works only while the order is sent or signed, at most once per 24 hours, and needs an email address on the customer. It sets signer_reminder_sent_at. Other cases return 400.
Countersign. countersign locks the order row, so two concurrent calls cannot create two contracts. It sets frozen: true and countersigned_at, then creates the contract (below) and emits the order.countersigned webhook (see Webhooks). The response is the order with contract_id set. If first_invoice_date was never set, the order's order_start_date is used, or today's date if that is also missing. The contract starts as awaiting_first_invoice. The contract's original_value_cents and current_value_cents start at the order's order_value_cents.
E-signature for orders
An envelope is a signature request created at the e-signature provider. The provider is DocuSign.
curl -X POST "https://api.ondayzero.com/api/v1/orders/ORDER_UUID/esign/send" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"document_url": "https://files.example.com/order-form.pdf", "subject": "Please sign: ORD-0007"}'document_url is required: either a public https URL, or an s3_key from an upload for the same business. DayZero does not render order PDFs, so you supply the document. Private, internal and cloud-metadata addresses are rejected. subject is optional (up to 200 characters; the default names the order).
The call creates an envelope addressed to the customer's email, stores esign_envelope_id, esign_status and esign_sent_at on the order, and, if the order was draft, moves it to sent. It returns 400 when:
- e-signature is not configured for the environment (
VALIDATION_426); - the order is not
draftorsent(VALIDATION_427); - the customer has no email (
VALIDATION_428); - an envelope is already
sentordeliveredfor the order (VALIDATION_430); - the order has no lines.
If the provider rejects the envelope, the call fails with 500.
GET /api/v1/orders/{order_id}/esign returns the envelope state: order_id, order_status, provider, envelope_id, esign_status (sent, delivered, completed, declined or voided), esign_sent_at and refreshed. It reads live from the provider when credentials are configured (refreshed: true) and otherwise returns the stored snapshot. It returns 400 if the order has no envelope (VALIDATION_431).
The provider reports back through POST /api/v1/webhooks/esign. That endpoint is for the provider, not for your integration: it is unauthenticated, checks an HMAC signature, and returns 403 without a valid one. A completed envelope moves a sent order to signed and stores the executed document URL when the provider supplies one. declined and voided only update esign_status. If the order was reverted to draft after sending, a completed event updates esign_status but leaves the order's status alone. Countersigning is never automatic: call countersign yourself after the order reaches signed.
Contracts
A contract is the live form of a countersigned order. Statuses:
| Status | Meaning |
|---|---|
awaiting_first_invoice |
Created at countersign; nothing billed yet |
active_drawing_down |
First invoice sent and it included a prepaid block |
active_billing |
First invoice sent with no prepaid block |
paused |
Billing and drafting stopped |
completed |
Term elapsed, or the prepaid block is fully consumed and billed |
Contract responses include order_id, project_id, customer_id, customer_name, number (CON-NNNN), status, currency, original_value_cents, current_value_cents, billed_cents, recognized_cents, deferred_remaining_cents (billed minus recognized, never below 0), billed_pct, earned_pct (fractions, for example 0.25), total_contracted_hours, hours_drawn, hours_remaining, first_invoice_date, end_date, and the flags can_be_paused, can_be_resumed and can_be_amended.
Create or promote a contract
A contract always comes from a countersigned order. POST /api/v1/contracts does the whole order lifecycle for you. Send exactly one of order_id or order; otherwise you get a 400.
# Promote an existing order
curl -X POST "https://api.ondayzero.com/api/v1/contracts" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"order_id": "019ab37c-c057-7000-8000-000000000010"}'order_idpromotes an existingdraft,sentorsignedorder. The call skips any step that is already done: it sends the order if it is a draft, marks it signed if it issent, then countersigns it. It returns400if the order already has a contract (VALIDATION_364) or has no lines.ordertakes a full order body, exactly as in Create an order. It creates the order, then runs the same send, sign and countersign steps. This is for agreements that were signed offline or are being migrated.
Promotion records the signing steps without any customer-facing step: nothing is emailed and no signature is collected. The response is the new contract. Both paths emit order.countersigned.
List, get, edit, pause and delete
GET /api/v1/contractsacceptsstatus(exact match on a status above),customer_id,cursorandlimit.GET /api/v1/contracts/{contract_id}returns one contract.PATCH /api/v1/contracts/{contract_id}editsnumber(up to 50 characters),first_invoice_date,end_date(both clearable withnull;end_datebeforefirst_invoice_dateis400) andline_items. A completed contract cannot be edited.line_itemsedits the originating order's lines in place. Each item with anidedits that line'sdescription,qtyorunit_price_cents. An item without anidappends a new line and then needsqtyandunit_price_cents, plus optionalcatalog_item_id,billing_rule,billing_timingandramp_schedule. Lines you omit are untouched.qtyandunit_price_centschanges, and appended lines, are accepted only while no money has moved (nothing billed or recognized, no approved drawdowns); afterwards use a change order (400). On success, the original and current values and the contracted hours are recomputed.statusis a shortcut for the lifecycle actions:completed,on_holdorpaused, oractiveorresume. Ifstatusis present, only the status change is applied and the other fields in that call are ignored.
POST /api/v1/contracts/{contract_id}/pauseworks on an active contract (awaiting_first_invoice,active_drawing_down,active_billing).POST .../resumeworks on apausedorcompletedcontract, and restoresawaiting_first_invoiceif nothing has been billed,active_drawing_downif the contract has contracted hours, andactive_billingotherwise.PATCH /api/v1/contracts/{contract_id}/hourstakes{"total_hours": 120}and sets the contracted hours (the burndown denominator). It cannot go below the hours already drawn (400) and is refused on a completed contract.POST /api/v1/contracts/{contract_id}/check-completioncompletes the contract if the term has elapsed or the prepaid block is consumed and billed, and returns the contract.DELETE /api/v1/contracts/{contract_id}works only while nothing is billed or recognized, no non-draft invoice is linked (voided and uncollectible ones do not count), and no drawdown is approved. Linked draft invoices and pending or returned drawdowns are deleted with it, and the order is kept as a countersigned order without a contract.
Invoices
Contract invoices pass two human gates before money moves: reviewing the invoice, and approving hours.
- Draft the first invoice.
POST /api/v1/contracts/{contract_id}/draft-invoices(no body) drafts one execution invoice containing theflat,prepaid_blockand upfrontrecurringlines, skipping zero-amount lines. It returns400if one was already drafted (VALIDATION_388), if there is nothing billable, or if the contract is completed. Drafting posts nothing to the ledger. - Draft later periods.
POST .../generate-period-invoicestakes{"period_date": "2026-12-15"}, any date inside the period, and drafts that period's invoice fromeach_periodrecurring lines andper_unitlines (approved, not yet billed hours times unit price, capped at the bought quantity). The period follows the business'sinvoice_cadencesetting, eithercalendar_monthorfour_week. It is idempotent per period, only works on an active contract, and only inside the contract term.POST .../invoicesdrafts an extra manual invoice with optionaldue_date(default 30 days out) andline_itemsofdescription,amount_centsandquantity; without lines it uses theeach_periodrecurring lines. - Review.
GET /api/v1/contracts/invoiceslists contract invoices, withcontract_id,status(defaultdraft;allfor every status),cursorandlimit. Items includenumber,status,total,amount_paid,balance_due,due_date,can_be_sentandline_items(description,quantity,unit_price,amount,billing_rule,order_line_id,prepaid).PATCH /api/v1/contracts/invoices/{invoice_id}edits a draft'sdescription,due_dateandline_items(description,quantity,amount_cents,billing_rule,order_line_id). The lines replace the old ones, and the total is recalculated. - Approve and send.
POST /api/v1/contracts/invoices/{invoice_id}/sendmovesdrafttoopen. The amount becomes billed on the contract. The prepaid portion is booked to Deferred Revenue, and the rest is booked to contract revenue and counted as recognized. The first sent invoice activates the contract.
If the business's O2C settings name an invoice_approver_role, only that role or an owner can call send; others get 403 (VALIDATION_406). Settings are read and written at GET and PUT /api/v1/contracts/settings.
Drawdowns (hours)
A drawdown records hours consumed against a contract.
curl -X POST "https://api.ondayzero.com/api/v1/contracts/CONTRACT_UUID/drawdowns" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"hours": 6.5, "entry_date": "2026-11-18", "description": "Integration work", "submitted_by": "dana@example.com", "submitted_via": "manual"}'hours (above 0) and entry_date (YYYY-MM-DD) are required. submitted_via is manual (default), slack or email. order_line_id attributes the hours to a line of this contract's order. It is validated, and unattributed hours draw the prepaid block. The entry is created pending. Recording is allowed on any contract that is not completed.
GET /api/v1/contracts/{contract_id}/drawdownslists a contract's entries, andGET /api/v1/contracts/drawdownsis the portfolio-wide queue. Both takestatus(pending,approvedorreturned),cursorandlimit.POST /api/v1/contracts/drawdowns/{drawdown_id}/approvetakes an optional{"hours": 6, "approved_by": "..."}. A correctedhourskeeps the submitted value inoriginal_hoursand setswas_edited. Approval draws down the block and recognizes revenue by the hours-percent method:approved hours / total contracted hours x prepaid amount booked. Recognition only moves up, never down. It emitsdrawdown.approved.POST .../drawdowns/{drawdown_id}/returnsets a pending entry toreturned.
Approval returns 400 when the contract is paused or completed, when nothing has been billed yet (VALIDATION_380), or when the hours would exceed the block (VALIDATION_371) or the attributed line's bought hours. If hours_approver_role is set in the O2C settings, only that role or an owner can approve or return (403, VALIDATION_405). Approving the last hours of a block completes the contract.
Reporting endpoints
| Endpoint | Returns |
|---|---|
GET /contracts/{id}/burndown |
block (total_hours, used_hours, remaining_hours, unattributed_hours, recognized_cents, deferred_remaining_cents) and per-line lines |
GET /contracts/summary |
Portfolio totals: contract value, billed, recognized, deferred, counts and counts_by_status |
GET /contracts/summary/by-type |
Contracted line value grouped by billing rule |
GET /contracts/cash-flow-forecast?weeks=13 |
Expected weekly inflow per contract (weeks from 1 to 52) |
GET /contracts/{id}/suggested-invoices |
Unlinked invoices ranked as likely matches, with score and reasons |
Change orders
A change order is a versioned amendment to a live contract. Statuses are draft, sent, signed and countersigned. Versions start at 1 per contract.
curl -X POST "https://api.ondayzero.com/api/v1/change-orders" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"contract_id": "019ab37c-c057-7000-8000-000000000020",
"description": "Add 40 prepaid hours",
"value_delta_cents": 600000,
"hours_delta": 40
}'contract_id and value_delta_cents (an integer that may be negative) are required. hours_delta is a signed decimal, or null/omitted to leave hours alone. delta_lines is a free-form JSON object for describing adds, removes and quantity changes. It is stored but never applied: only value_delta_cents and hours_delta change the contract. Creating is refused with 400 (VALIDATION_367) if the contract is completed; paused contracts can be amended.
| Endpoint | Effect |
|---|---|
GET /change-orders |
List; filters contract_id, status, plus cursor and limit |
GET /change-orders/{id} |
One change order (version, signed_file_url, sent_at, signed_at, countersigned_at, can_be_* flags) |
PUT /change-orders/{id} |
Edit description, value_delta_cents, hours_delta or delta_lines on a draft. Send hours_delta: null to clear it |
POST /change-orders/{id}/send |
Emails the change order to the contract customer (customer_email on the response), then moves draft to sent. On a sent change order it emails again (status unchanged, sent_at updated); replies go to the user who sent it |
POST /change-orders/{id}/sign |
sent to signed |
POST /change-orders/{id}/countersign |
signed to countersigned, and applies the deltas |
POST /change-orders/{id}/revert-to-draft |
sent or signed back to draft; clears the timestamps and signed file |
DELETE /change-orders/{id} |
Drafts only |
None of these calls take a body. send is the only one that notifies anyone: it emails the customer, and the status and sent_at change only after the email goes out. sign and countersign collect no signature; they only record the step. Signed and countersigned change orders cannot be sent.
| HTTP | Code | Cause |
|---|---|---|
400 |
VALIDATION_873 |
The contract's customer has no email address |
400 |
INTEGRATION_092 |
The email could not be delivered; nothing changed |
403 |
Demo business | |
409 |
CONFLICT_101 |
Emailed less than 5 minutes ago |
Countersign applies the delta, exactly once. The change order and the contract are both locked. current_value_cents becomes current_value_cents + value_delta_cents, and total_contracted_hours becomes total_contracted_hours + hours_delta when hours_delta is set. original_value_cents never changes. The call fails with 400, and nothing is applied, if:
- the new current value would be below 0 (
VALIDATION_404); - the new hours total would be below the hours already drawn (
VALIDATION_408); - the contract is completed.
Hours-percent recognition uses the new hours total from the next drawdown approval onward. Revenue already recognized is not written back.
Revenue schedules and deferred revenue
DayZero has two read/write families for deferred revenue. Both book to a Deferred Revenue liability and release it to revenue with a recognition journal entry, Dr Deferred Revenue / Cr Revenue.
Revenue schedules (/revenue-schedules)
A revenue schedule is one tranche with source, source_key, recognition_method, period_start, period_end, amount_cents, currency, revenue_ledger_name, recognized, recognized_at, journal_entry_id and booking_journal_entry_id. A tranche becomes due when period_end is on or before the run date.
source |
Created by |
|---|---|
preorder |
POST /revenue-schedules/preorders |
manual |
Contracts. Each prepaid block sent on a contract invoice creates a parked hours_percent tranche (reference_type: "contract"); each hours recognition and each recognized-at-billing portion is recorded as an already-recognized tranche |
invoice |
Invoice deferrals (below) |
asc606, shopify_subscription, stripe_subscription, recharge |
Other sources the engine can hold. They are filterable in lists and the waterfall |
recognition_method is straight_line, point_in_time or hours_percent. GET /api/v1/revenue-schedules takes source, limit (default 100, max 500) and offset, and returns schedules and count (the rows on this page).
Pre-orders. Book cash received ahead of fulfillment:
curl -X POST "https://api.ondayzero.com/api/v1/revenue-schedules/preorders" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"order_reference": "PRE-1001", "amount_cents": 250000, "order_date": "2026-11-01", "receive_via": "cash"}'order_reference (your stable identifier) and amount_cents (above 0) are required. receive_via is the account debited: clearing (default), cash or ar. revenue_ledger_name defaults to Pre-order Revenue, currency to USD, and order_date to today. It posts Dr <cash, AR or clearing> / Cr Deferred Revenue and parks one tranche. The call is idempotent on order_reference: repeating it returns the existing tranche.
Release it on shipment with POST /api/v1/revenue-schedules/preorders/fulfill and {"order_reference": "PRE-1001", "fulfillment_date": "2026-11-20"} (the date defaults to today). It sets the tranche due on that date and posts the recognition entry. An unknown or already recognized reference is a no-op, not an error: look at rows_posted in the response (0 means nothing was posted; recognized_cents is always 0 on this endpoint, so read the amount from the tranche).
Run recognition. POST /api/v1/revenue-schedules/recognize (no body) posts every due tranche in the business whose source the engine posts for: preorder, manual, invoice, stripe_subscription and recharge. Other sources are posted by their own jobs. The response is success, as_of_date, rows_due, rows_posted, recognized_cents and errors. A tranche that fails is listed in errors and the rest still post. Re-running is safe because a posted tranche is skipped.
Waterfall. GET /api/v1/revenue-schedules/waterfall (optional source) returns buckets of period (YYYY-MM) and amount_cents for tranches not yet recognized, oldest first, plus total_deferred_cents. Parked tranches that wait on fulfillment or on hours (pre-orders and prepaid blocks) sit at period_end 9999-12-31 and show under 9999-12.
Invoice deferrals (/deferred-revenue)
A deferral moves an invoice's booked revenue into Deferred Revenue and schedules its release. Posting and scheduling happen on POST. Recognition posts later.
curl -X POST "https://api.ondayzero.com/api/v1/deferred-revenue/invoices/INVOICE_UUID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"unit": "months", "method": "straight_line", "period_count": 12, "start_date": "2026-12-01", "notes": "Annual support"}'| Field | Notes |
|---|---|
period_count |
Required, 1 to 600 monthly periods |
deferred_amount_cents |
Defaults to the invoice's revenue (total less sales tax). It cannot exceed that amount |
unit |
months (default), hours or custom |
total_units |
The denominator. Defaults to period_count for months; required for the other units |
method |
straight_line (default) fills every period evenly. custom leaves each period's units empty, and nothing posts until they are filled in |
start_date |
First period. Defaults to the current month |
revenue_ledger_name |
Defaults to the account the invoice booked revenue to |
periods |
Optional per-period period_start and units for a custom schedule; its length must equal period_count |
The invoice must already have booked revenue (not draft, void or uncollectible), or you get 400 (VALIDATION_457). An invoice that already has a deferral returns 409 (CONFLICT_034). The response is the deferral: deferred_amount_cents, recognized_cents, remaining_cents, status (active, completed or cancelled), the terms, journal entry IDs, and periods (sequence, period_start, period_end, units, amount_cents, recognized, recognized_at, journal_entry_id).
| Endpoint | Purpose |
|---|---|
GET /deferred-revenue |
Deferrals with their periods. Filters status, customer_id; limit (default 100, max 500) and offset. Also returns count and total deferred, recognized and remaining cents |
GET /deferred-revenue/{deferral_id} |
One deferral |
GET /deferred-revenue/invoices/{invoice_id} |
is_deferred, deferrable_revenue_cents (the cap), the live deferral, and suggested_deferred_amount_cents (the sum of the invoice's lines tagged deferred; null once a deferral exists) |
PUT /deferred-revenue/{deferral_id} |
Re-cut the not-yet-posted periods: unit, method, period_count, total_units, start_date, revenue_ledger_name, notes. Posted periods are untouched |
POST /deferred-revenue/{deferral_id}/periods/{period_id} |
Custom schedules: set units, optionally recognition_date and post_now (default true), so the period can post |
POST /deferred-revenue/{deferral_id}/cancel |
Body {} or {"as_of": "2026-12-31"}. Releases the remaining balance to revenue and deletes the unposted periods |
POST /deferred-revenue/recognize |
Body {} or {"as_of": "2026-12-31"}. Posts every period whose end date has passed. Returns as_of, periods_due, periods_posted, recognized_cents and errors. Safe to re-run |
GET /deferred-revenue/matrix |
Invoices against recognition months. status defaults to active. Returns periods, rows, period_totals and customer_totals |
GET /deferred-revenue/recognized |
Recognized and upcoming revenue grouped by month, method and source (invoice or contract). Filters start_date and end_date (on period end), limit and offset; returns rows, method_totals and totals |
Contract prepaid blocks do not appear under /deferred-revenue deferrals. They are revenue-schedule tranches; GET /deferred-revenue/recognized includes them with source: "contract".
O2C reports (/contracts/reports)
GET /api/v1/contracts/reports/{report_type}?format=csv|xlsx streams a file download (csv is the default). report_type is contract_register, ar_aging, deferred_revenue_waterfall, hours_ledger or billing_schedule; an unknown type or format is a 400. Each download is written to the audit log as an export. These are the same files as the Reports page of the Order to Cash workspace.
From an AI agent (MCP)
Deals, orders and contracts have MCP tools (see MCP Server); every write takes dry_run and goes through approval. Change orders, drawdowns, revenue schedules and deferred revenue are REST only.
| Task | Tool |
|---|---|
| Read | list_deals / get_deal, list_orders / get_order, list_contracts / get_contract |
| Deals | create_deal, bulk_create_deals, update_deal, bulk_update_deals, convert_deal_to_order (deal_id, project_name) |
| Orders | create_order, bulk_create_orders, update_order (lines replaces all lines), advance_order_status (target_status: draft, sent, signed or countersigned), attach_order_document (order_id, s3_key) |
| Contracts | create_contract (order_id or order), update_contract, bulk_create_contracts, bulk_update_contracts, link_invoice_to_contract |
Errors
Business-rule failures are 400 with a stable code; branch on code, not on message. A missing record is 404 (NOT_FOUND_*), including a record that belongs to another business. Duplicate deferrals are 409. The O2C codes you are most likely to see:
| Code | Meaning |
|---|---|
VALIDATION_358 / VALIDATION_359 / VALIDATION_360 / VALIDATION_361 |
Order cannot be updated / sent / signed / countersigned in its current status |
VALIDATION_362 |
Countersigned order with a live contract cannot be deleted |
VALIDATION_363 |
Order has no lines |
VALIDATION_364 |
Order already has a contract |
VALIDATION_399 |
Discount exceeds the order subtotal |
VALIDATION_367 / VALIDATION_368 |
Contract is completed and cannot be amended / the action needs an active contract |
VALIDATION_390 |
Contract has billed or recognized revenue and cannot be deleted |
VALIDATION_405 / VALIDATION_406 |
Your role may not approve hours / send contract invoices (403) |
VALIDATION_450 |
Deal was converted to an order and is read-only |
Use Accept: application/json; see Error Handling for the full envelope and retry rules.
What is not covered
Routes under /contracts for reordering a prepaid block, issuing credit memos, recording payments on contract invoices, payment reminders, and inbound hours or order PDF intake are not described here. Neither are the outbound O2C webhook endpoints under /webhooks/o2c (see Webhooks) nor the Slack hours receiver. The OpenAPI reference lists every operation and schema.