Projects and Time Tracking
A project (a job or engagement) is a label you carry on the records that belong to it. Journal entry lines, invoices, orders, contracts and deals can all point at a project. A time entry records work done for a customer or project, at an hourly rate, and can be pulled onto a draft invoice. Two route families cover this:
/api/v1/projects— create, list, update, deactivate and delete projects, and the profitability report./api/v1/time-entries— log and edit time, list billable time, bill time and vendor expenses onto a draft invoice.
All requests need Authorization: Bearer dz_your_token_here and x-business-id: YOUR_BUSINESS_ID. Money is integer cents and dates are YYYY-MM-DD. Successful responses are wrapped as {"success": true, "data": …}; list routes put a flat items array inside data (no pagination, no cursor).
Permissions and gating
| Call | Needs |
|---|---|
Any GET |
read |
POST, PUT |
write |
DELETE (deactivate or delete a project, delete a time entry) |
delete |
Business owners, admins and firm staff hold all three; direct members hold read and write; viewers hold read only. Beyond role:
- A token with the
read_onlyscope can onlyGET(see Authentication). - Writes are refused with
403BILLING_001when the business has neither an active trial nor an active subscription. Reads still work. - Team members with a restricted sidebar menu get
403AUTH_012on both families unless their menu includes Projects & Time. Owners and admins are never restricted.
Projects
Fields
| Field | Type | Notes |
|---|---|---|
name |
string | Required, 1–255 characters |
code |
string | Optional, up to 100 characters |
description |
string | Optional |
status |
string | active (default), completed or on_hold; anything else is a 400 |
customer_id |
UUID | Optional; must be a customer of this business (404 otherwise) |
start_date, end_date |
date | Optional |
is_active |
boolean | Update only. false retires the project from pickers and default lists |
code and name must each be unique among the business's active projects, compared case-insensitively and ignoring surrounding spaces. A clash is a 400 (GEN_002) that says whether the code or the name collided, so a lookup by code always resolves to one project.
Create a project
curl -X POST "https://api.ondayzero.com/api/v1/projects" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"name": "Website redesign",
"code": "WEB-2026",
"status": "active",
"customer_id": "0192a4f0-1b2c-7d3e-8f4a-5b6c7d8e9f01",
"start_date": "2026-02-01"
}'The response data is the project: id, business_id, name, code, sku, description, status, customer_id, customer_name, start_date, end_date, is_active, created_at, updated_at. (sku is read-only here; only bulk create accepts it.)
List and fetch
curl "https://api.ondayzero.com/api/v1/projects?status=active&search=web" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"Query parameters, all optional: status (active, completed, on_hold; send only these values), customer_id, include_inactive (default false; deactivated projects are hidden unless true), code and name (exact match, case-insensitive), and search (case-insensitive substring of code or name). Results are ordered by name. GET /api/v1/projects/{project_id} returns one project (404 if it is not in this business).
Update, deactivate, delete
PUT /api/v1/projects/{project_id} takes any of the create fields plus is_active; only the fields you send change.
curl -X PUT "https://api.ondayzero.com/api/v1/projects/PROJECT_UUID" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{"status": "completed", "end_date": "2026-05-31"}'DELETE /api/v1/projects/{project_id}deactivates the project (setsis_activetofalse) and returns it. Tagged records keep their link, so history and profitability stay intact. Reactivate withPUT … {"is_active": true}.DELETE /api/v1/projects/{project_id}/permanentremoves the row for a project created by mistake and returns{"success": true, "message": "Project deleted successfully.", "code": "DELETE_054"}. If journal entry lines, invoices, time entries, contracts, orders or deals still point at it, the call is a400that lists the counts. Add?force=trueto delete anyway; the project tag is then cleared from those records.
Bulk create
POST /api/v1/projects/bulk creates 1–100 projects in one request. A row is a project body plus sku (up to 100 characters) and either customer_id or customer_name. customer_id wins when both are sent. A name is matched case-insensitively and exactly against the business's active customers. Set create_missing_customers to true to create a customer for any name that matches nobody.
curl -X POST "https://api.ondayzero.com/api/v1/projects/bulk" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"create_missing_customers": false,
"rows": [
{"name": "Kitchen remodel", "code": "KIT-01", "customer_name": "Acme Corp"},
{"name": "Office fit-out", "code": "OFF-02", "status": "on_hold"}
]
}'Rows succeed or fail independently and the HTTP status is 200 either way. data carries total_requested, total_created, total_failed and results, one entry per row in input order:
{
"index": 0,
"name": "Kitchen remodel",
"success": false,
"project": null,
"error": {
"code": "CUSTOMER_AMBIGUOUS",
"message": "2 customers are named 'Acme Corp'. Pass customer_id to disambiguate.",
"candidates": [{"id": "…", "name": "Acme Corp", "email": "ap@acme.example"}]
}
}Row error codes: DUPLICATE_CODE and DUPLICATE_NAME (clash with an existing active project or an earlier row of the same request), CUSTOMER_NOT_FOUND (with up to 5 near-match candidates), CUSTOMER_AMBIGUOUS (up to 10 candidates) and CREATE_FAILED. A request with no rows or more than 100 is a 400.
Tagging records with a project
The profitability report reads the project_id stored on journal entry lines. You get it there by:
- sending
project_idon a line when you create or edit a journal entry (see Journal Entries); - setting
project_idon an invoice (see Invoices). When the invoice is finalized, its journal lines (receivable, revenue and any cost of goods sold) are stamped with that project; project_idon orders, contracts, deals and time entries links those records to the project.
/api/v1/entity-tags with model_type project (see Tags) attaches a tag to the project record itself. That is a separate label and does not affect profitability.
Project profitability
curl "https://api.ondayzero.com/api/v1/projects/profitability?start_date=2026-01-01&end_date=2026-03-31" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID"start_date and end_date are both required and inclusive; a missing or malformed one is a 400. The report sums journal entry lines dated in the range, grouped by project:
- revenue is credits minus debits on revenue accounts;
- expenses is debits minus credits on expense accounts.
{
"success": true,
"data": {
"start_date": "2026-01-01",
"end_date": "2026-03-31",
"rows": [
{"project_id": "0192a4f0-…", "project_name": "Website redesign", "code": "WEB-2026",
"revenue": 1200000, "expenses": 450000, "net_income": 750000},
{"project_id": null, "project_name": "Unassigned", "code": null,
"revenue": 300000, "expenses": 800000, "net_income": -500000}
],
"totals": {"revenue": 1500000, "expenses": 1250000, "net_income": 250000}
}
}Only projects with activity in the range appear, sorted by net_income descending. Lines with no project are grouped in a row with project_id null and name Unassigned, always last, so totals still match the books. Deactivated projects are included.
GET /api/v1/projects/{project_id}/profitability?start_date=…&end_date=… returns the same shape for one project: 404 if the project does not exist, otherwise rows holds at most that project's row (empty when it had no activity) and totals are for that row.
Logged time does not feed this report. Profitability comes only from tagged journal entry lines. Hours show up as revenue only after they are billed on an invoice that carries the project and is finalized, and the cost of doing the work only appears if you book it to expense lines tagged with the project. Compare logged hours to profit by combining GET /api/v1/time-entries?project_id=… with this report.
Time entries
Fields
| Field | Type | Notes |
|---|---|---|
entry_date |
date | Required; the day the work was done |
duration_minutes |
integer | Required, greater than 0. Minutes, not hours |
description |
string | Optional; becomes the invoice line text |
person_name |
string | Optional free text for who did the work |
customer_id, project_id |
UUID | Optional; must belong to this business (404 otherwise) |
catalog_item_id |
UUID | Optional service item; its name is the invoice line label when there is no description |
billable |
boolean | Default true |
rate_cents |
integer | Optional, 0 or more. Hourly rate in cents |
status |
string | unbilled or billed. Set by the API: new entries are unbilled. Settable on update only |
is_active |
boolean | Update only |
The response adds customer_name, project_name, invoice_id (set once billed), billable_amount, created_at and updated_at. billable_amount is duration_minutes / 60 × rate_cents, rounded to a whole cent, and is 0 when there is no rate.
Log time
curl -X POST "https://api.ondayzero.com/api/v1/time-entries" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"entry_date": "2026-03-12",
"duration_minutes": 90,
"description": "Homepage wireframes",
"person_name": "Dana Lee",
"customer_id": "0192a4f0-1b2c-7d3e-8f4a-5b6c7d8e9f01",
"project_id": "0192a4f0-5555-7d3e-8f4a-5b6c7d8e9f02",
"billable": true,
"rate_cents": 15000
}'This entry has billable_amount 22500 ($225.00 for 1.5 hours at $150.00).
List, fetch, update, delete
GET /api/v1/time-entriesfilters:customer_id,project_id,status(unbilledorbilled; send only these values) andinclude_inactive(defaultfalse).GET /api/v1/time-entries/{time_entry_id}returns one entry.PUT /api/v1/time-entries/{time_entry_id}changes any field above; only the fields you send change. The API does not lock billed entries, but editing one does not rewrite the invoice line already created from it.DELETE /api/v1/time-entries/{time_entry_id}deactivates the entry (is_activefalse) and returns it. Inactive entries are hidden from lists unlessinclude_inactive=trueand can no longer be billed.
Bill time onto a draft invoice
GET /api/v1/time-entries/billable lists entries that are active, billable and unbilled, each with its computed billable_amount. It takes optional customer_id and project_id filters. Note that it also returns entries without a rate; those cannot be billed until you set one.
Then add the ones you choose to a draft invoice:
curl -X POST "https://api.ondayzero.com/api/v1/time-entries/add-to-invoice" \
-H "Authorization: Bearer dz_your_token_here" \
-H "x-business-id: YOUR_BUSINESS_ID" \
-H "Content-Type: application/json" \
-d '{
"invoice_id": "019ab37c-c057-7000-8000-000000000010",
"time_entry_ids": ["0192a4f0-7777-7d3e-8f4a-5b6c7d8e9f03"]
}'{"success": true, "data": {"invoice_id": "019ab37c-c057-7000-8000-000000000010",
"added_count": 1, "added_amount": 22500, "invoice_total": 272500}}Each entry becomes one custom line (quantity 1, unit price equal to its billable amount) described as Homepage wireframes (1.50 hrs @ $150.00/hr). The label is the entry's description, else the catalog item name, else Billable time. invoice_total is the sum of the invoice's line amounts after the addition. Each entry is marked billed and its invoice_id is set.
The call is all-or-nothing; nothing changes if any check fails:
| Status | Cause |
|---|---|
404 |
The invoice or one of the time entries is not in this business |
400 |
The invoice is not a draft ("Time can only be added to a draft invoice."); finalize happens later, see Invoices |
400 |
An entry is inactive, not billable or already billed ("One or more time entries are not available to bill.") |
400 |
An entry has no rate_cents ("Time entry has no billing rate set.") |
Adding time does not set the invoice's own project_id. Set it on the invoice if you want the revenue to land in that project's profitability.
Bill vendor expenses
Billable vendor costs can be on-billed the same way. GET /api/v1/time-entries/billable-expenses (no filters) lists unbilled expense links as items of id, source_model, source_id, bill_number, vendor_id, description and amount (cents). POST /api/v1/time-entries/add-expenses-to-invoice takes invoice_id (a draft) and document_link_ids (the id values from that list), appends one custom line per expense and returns invoice_id, added_count, added_amount and invoice_total. The same 404 and draft-only 400 rules apply, and an expense that is already on an invoice or has no amount is a 400. There is no endpoint to create these links; they come from the Xero sync, which imports Xero's linked (on-billed) costs.
Errors
Errors use the shared envelope in Error Handling. The ones specific to this area:
| Status | code |
When |
|---|---|---|
400 |
GEN_002 |
Bad status value, duplicate project code or name, non-positive duration, negative rate, malformed date, or any of the billing rules above |
403 |
AUTH_012 |
Your role lacks the read, write or delete permission the call needs, or your menu excludes Projects & Time |
403 |
AUTH_048 |
The token's scope does not allow the method (for example read_only on a POST) |
404 |
NOT_FOUND_001 |
Project, customer or time entry not found in this business |
Not covered
The project_id fields on orders, contracts, deals and recurring invoices belong to those resources' own routes. The project list export and the bulk project update available to assistants are not part of /api/v1/projects. The MCP server also exposes project tools (list_projects, get_project, create_project, update_project, bulk_create_projects, bulk_update_projects); see MCP Server.