Time entries
Tracked time. Reads require time-entries:read, writes time-entries:write.
Changed: time entries used to ride the
projects:readability. They now have their own scope, so a token issued with onlyprojects:readno longer reads them. Reissue the token withtime-entries:read(andtime-entries:writeif it needs to log hours).
Feature-gated
Like projects, time entries are gated by the per-book projects_enabled toggle — a book with the feature off returns a blank 404, before validation runs.
Operations
| Method & path | Ability |
|---|---|
GET /api/v1/time-entries |
time-entries:read |
GET /api/v1/time-entries/{timeEntry} |
time-entries:read |
POST /api/v1/time-entries |
time-entries:write |
PATCH /api/v1/time-entries/{timeEntry} |
time-entries:write |
DELETE /api/v1/time-entries/{timeEntry} |
time-entries:write |
Get a time entry
curl https://billey.nl/api/v1/time-entries/3001 \
-H "Authorization: Bearer $TOKEN"
{
"data": {
"id": 3001,
"project_id": 55,
"invoice_id": null,
"is_invoiced": false,
"user_id": null,
"product_id": 12,
"work_type_name": "Development",
"hourly_rate": "90.00",
"date": "2026-07-21",
"hours": "7.75",
"description": "Layout work"
}
}
work_type_name and hourly_rate are saved with the entry when it is logged. A later change to a product price or a project rate never changes them. product_id is null when the entry has no work type, or when its product was deleted. user_id is null for entries logged through the API and for entries logged before billey recorded who logged them. hourly_rate is null for entries that were already invoiced before work types existed.
is_invoiced is derived — it tells you whether the entry has already been billed. Uninvoiced entries can be claimed onto a new invoice by passing their ids as time_entry_ids when you create an invoice.
Entries belonging to a deleted project are not returned, even when the project itself was retained because it had billed hours.
Log a time entry
project_id, date, hours and description are all required. As a create it takes an Idempotency-Key.
curl -X POST https://billey.nl/api/v1/time-entries \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "project_id": 55, "date": "2026-07-21", "hours": "7.75", "description": "Layout work" }'
project_id must belong to your book, and must not be a project you have deleted.
product_id is the work type. A project can have work types (hourly products, each with an optional rate for that project). When the project has work types, product_id is required and must be one of them. When it has none, leave product_id out. You cannot set user_id: entries logged through the API have no user.
hourly_rate is optional. Send it to override the rate that is saved with the entry. Leave it out to use the work type rate, or the project rate when there is no work type. On PATCH, send null to reset the rate to the work type rate (or the project rate). It is dot-decimal, minimum 0.
hours is dot-decimal only, as everywhere else in this API — see Money and decimals. The minimum is 0.01. description is capped at 255 characters.
invoice_id is not accepted, here or on PATCH. Hours become billed by creating an invoice from the project, which claims them as a side effect. Letting a caller set invoice_id directly would mark hours as billed that no invoice line actually covers.
The idempotency key is scoped to the project
Everywhere else in this API an Idempotency-Key is unique per book. For time entries it is unique per project, because a time entry belongs to its project rather than directly to the book. So:
- Reusing a key on the same project replays the original entry and returns
200. - Reusing the same key on a different project creates a second entry.
Generate a fresh key per request — uuidgen above — and this never comes up.
Update a time entry
PATCH is a partial merge — only the keys you send are changed, and every field is optional.
curl -X PATCH https://billey.nl/api/v1/time-entries/3001 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "hours": "8.25" }'
project_id may be changed to move the entry to another project in the same book.
Refused with 409 while the entry is invoiced. Billed hours are frozen so they cannot drift out of agreement with the invoice line they were billed on — an entry silently edited from 8 hours to 2 after billing would leave the invoice saying one thing and your records another. To correct work that has already been billed, credit the invoice and re-invoice.
The freeze lifts if the claiming invoice is deleted. Only a draft invoice can be deleted, so its hours were never sent to anyone and are legitimately editable again.
Delete a time entry
DELETE permanently deletes the entry and returns 204 — time entries are not soft-deleted, and there is no include_deleted view to recover one from.
Refused with 409 once the entry has been invoiced. The per-day breakdown behind a billed invoice line exists only on these entries: invoicing groups hours by description and writes the summed total onto the line item, so the individual days are not recorded anywhere else. It is also the record a deleted project's invoices depend on to name the project they billed — see projects.
See the OpenAPI spec for the full field list.