Skip to content

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_only scope can only GET (see Authentication).
  • Writes are refused with 403 BILLING_001 when the business has neither an active trial nor an active subscription. Reads still work.
  • Team members with a restricted sidebar menu get 403 AUTH_012 on 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

bash
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

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

bash
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 (sets is_active to false) and returns it. Tagged records keep their link, so history and profitability stay intact. Reactivate with PUT … {"is_active": true}.
  • DELETE /api/v1/projects/{project_id}/permanent removes 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 a 400 that lists the counts. Add ?force=true to 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.

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

json
{
  "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_id on a line when you create or edit a journal entry (see Journal Entries);
  • setting project_id on 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_id on 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

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

bash
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-entries filters: customer_id, project_id, status (unbilled or billed; send only these values) and include_inactive (default false).
  • 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_active false) and returns it. Inactive entries are hidden from lists unless include_inactive=true and 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:

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