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.