Projects

Projects, each with its embedded client summary. Reads require projects:read, writes projects:write.

Feature-gated

Projects are gated by the per-book projects_enabled toggle. On a book with the feature switched off, every endpoint below returns a blank 404 — the same shape as a missing resource, and before validation runs. If you get an unexpected 404 on a book you know has projects, check the toggle in that book's settings.

Operations

Method & path Ability
GET /api/v1/projects projects:read
GET /api/v1/projects/{project} projects:read
POST /api/v1/projects projects:write
PATCH /api/v1/projects/{project} projects:write
DELETE /api/v1/projects/{project} projects:write

Get a project

curl https://billey.nl/api/v1/projects/55 \
  -H "Authorization: Bearer $TOKEN"
{
  "data": {
    "id": 55,
    "name": "Website redesign",
    "status": "active",
    "hourly_rate": "95.00",
    "btw_percentage": "21.00",
    "budget_hours": "40.00",
    "client": { "id": 7, "name": "Example Client B.V." }
  }
}

status is one of active, completed, archived. Tracked time against a project is exposed as time entries, which are writable and carry their own time-entries:read / time-entries:write abilities — projects:read and projects:write do not extend to them.

Create a project

client_id, name, hourly_rate and btw_percentage are required; product_id, description and budget_hours are optional. As a create it takes an Idempotency-Key.

curl -X POST https://billey.nl/api/v1/projects \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "client_id": 7, "name": "Website redesign", "hourly_rate": "95.00", "btw_percentage": "21.00", "budget_hours": "40" }'

A new project is always created active. Any status you send here is ignored — PATCH it afterwards to change it.

hourly_rate and budget_hours are dot-decimal only, as everywhere else in this API — see Money and decimals. Omitting budget_hours, or sending it as null, means "no budget"; it is never turned into 0.

Update a project

PATCH is a partial merge — only the keys you send are changed, and every field is optional. This is also where status transitions live; there are no separate transition endpoints.

curl -X PATCH https://billey.nl/api/v1/projects/55 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "completed" }'

Delete a project

DELETE always returns 204. What happens underneath depends on whether the project has ever been invoiced.

Never invoiced — the project is deleted permanently, and its time entries go with it.

Has invoiced hours — the project is retained internally so its invoices can still say which project they billed. An invoice carries no project reference of its own; it reaches one only through the time entries that were billed on it, so discarding the project would make that question permanently unanswerable.

Either way the project is gone as far as this API is concerned: GET on it returns 404, it drops out of the list, and so do its time entries. There is no include_deleted view and no undelete. If you want the project to stay visible, archive it instead (status: "archived").

Because a project with billed hours survives its own deletion, the hours themselves are frozen — see time entries.

See the OpenAPI spec for the full field list.

Something inaccurate or missing? support@billey.nl