A time entry is a date plus a duration in minutes on one ticket, always logged as the caller (there is no logging on behalf of someone else). SAQ has no timer; startedAt is an optional wall-clock time. Every time operation requires a workspace user; client users get 403 forbidden. Once a period is closed its work facts are frozen, and once a billing run has allocated an entry it cannot change at all.
GET /api/w/{slug}/time
Time entries and periods in a date range for one person. Token: yes, read.
Query parameter (TimeQuery) |
Type | Required | Meaning |
|---|---|---|---|
from, to |
YYYY-MM-DD |
yes | Inclusive range, at most 366 days |
userId |
uuid | no | Another workspace user; Owner or Admin only. Default: the caller |
ticketId |
uuid | no | Only entries on this ticket |
Request
curl "https://saq.no/api/w/acme/time?from=2026-09-14&to=2026-09-20" \
-H 'Authorization: Bearer saq_YOUR_TOKEN'
Response 200 (Week)
{
"entries": [
{
"id": "01924c3e-1a20-7b31-8c42-9d5e6f7a8b9c",
"ticketId": "01924c3e-8f10-7a22-b3c4-5d6e7f8a9b0c",
"ticketRef": "ACME-184",
"title": "Login page shows stale session banner",
"userId": "01924c3e-0004-7000-8000-000000000001",
"userName": "Kari Nordmann",
"projectId": "01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7e",
"projectName": "Website",
"billingClientId": "01924c3e-0005-7000-8000-000000000001",
"clientName": "Acme AS",
"categoryId": "01924c3e-0007-7000-8000-000000000001",
"periodId": "01924c3e-0008-7000-8000-000000000009",
"spentOn": "2026-09-18",
"startedAt": "09:30:00",
"timezone": "Europe/Oslo",
"minutes": 90,
"note": "Reproduced and fixed the banner.",
"billable": true,
"correctsEntryId": null,
"billed": false,
"closedAt": null,
"closedBy": null
}
],
"periods": [
{
"id": "01924c3e-0008-7000-8000-000000000009",
"periodStart": "2026-09-01",
"periodEnd": "2026-09-30",
"closedAt": null,
"closedBy": null
}
],
"expectedWeekMinutes": 2250,
"timezone": "Europe/Oslo",
"userId": "01924c3e-0004-7000-8000-000000000001"
}
| Field | Meaning |
|---|---|
entries[] (TimeEntry) |
id, ticketId, ticketRef, title, userId, userName, projectId, projectName, billingClientId, clientName, categoryId, periodId, spentOn, startedAt, timezone, minutes (negative for a correction), note, billable, correctsEntryId, billed, closedAt, closedBy |
periods[] (Period) |
id, periodStart, periodEnd, closedAt, closedBy for periods overlapping the range |
expectedWeekMinutes |
The person's weekly target, or null |
timezone |
IANA timezone the entries were logged in |
userId |
Whose entries these are |
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | range_limit (more than 366 days or to before from) |
forbidden |
403 | Client user; or userId is someone else and the caller is not Owner or Admin |
not_found |
404 | userId is not a workspace user |
POST /api/w/{slug}/time
Log time on a ticket. Token: yes, write. The ticket must be visible, be in a project (Inbox tickets refuse), and the caller must be able to log time on it (permissions.logTime). The entry is attributed to the ticket's project, billing client, and the caller's consultant category (project override, then membership default).
Body field (TimeInput) |
Type | Required | Validation |
|---|---|---|---|
ticketId |
uuid | yes | The ticket |
spentOn |
YYYY-MM-DD |
yes | Not before the first period start; not in a closed period |
startedAt |
HH:MM or HH:MM:SS, or null |
no | Wall clock in the caller's timezone |
minutes |
integer ≥ 1 | yes | Duration |
note |
string or null | no | At most 10 000 characters; staff only, never shown to clients |
billable |
boolean | no | Default true |
Request
curl -X POST https://saq.no/api/w/acme/time \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"ticketId": "01924c3e-8f10-7a22-b3c4-5d6e7f8a9b0c",
"spentOn": "2026-09-19",
"startedAt": "13:00",
"minutes": 45,
"note": "Verified the fix on staging.",
"billable": true
}'
Response 201 { "id": "01924c3e-1a21-7b32-8c43-9d5e6f7a8b9d" }
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | Validation failed; ticket_unrouted (ticket is in the Inbox) |
conflict |
409 | before_first_period_note (date precedes the first period), closed_period_log (period is closed) |
forbidden |
403 | Client user, no permissions.logTime, or missing write scope |
not_found |
404 | Ticket not visible |
PATCH /api/w/{slug}/time/{id}
Change an entry. Token: yes, write. Own entries; Owner or Admin may edit anyone's. In a closed period only billable, billingClientId, and categoryId may change, and only by an Owner or Admin from a browser session. A billed entry is frozen.
Body field (TimePatch) |
Type | Validation |
|---|---|---|
spentOn |
date | Destination period must be open |
startedAt |
HH:MM[:SS] or null |
|
minutes |
non-zero integer | Negative only on a correction |
note |
string (≤10 000) or null | |
billable |
boolean | |
billingClientId |
uuid or null | Owner or Admin, browser session only |
categoryId |
uuid or null | Owner or Admin, browser session only |
curl -X PATCH https://saq.no/api/w/acme/time/01924c3e-1a21-7b32-8c43-9d5e6f7a8b9d \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "minutes": 60 }'
Response 200 { "id": "01924c3e-1a21-7b32-8c43-9d5e6f7a8b9d" }
| Error | Status | details.reason |
|---|---|---|
conflict |
409 | entry_billed, closed_period_edit, destination_period_closed, before_first_period, attribution_diverges |
invalid |
400 | negative_minutes, correction_fixed |
forbidden |
403 | Someone else's entry (not Owner or Admin); token on a closed-period or attribution change |
not_found |
404 | Unknown entry, unknown_category |
DELETE /api/w/{slug}/time/{id}
Delete an entry in an open period. Token: no (browser session only). Own entries; Owner or Admin may delete anyone's. Response 200 { "ok": true }.
| Error | Status | details.reason |
|---|---|---|
conflict |
409 | closed_period_delete ("create a correction"), entry_billed |
forbidden |
403 | Not the author and not Owner or Admin |
POST /api/w/{slug}/time/{id}/corrections
Correct an entry that lies in a closed period. Token: no (browser session only). Owner or Admin. The correction is dated today in the caller's timezone, inherits the original's author and attribution, and the running net of an entry and its corrections can never go negative. The app shows it as an "Adjustment".
Body (CorrectionInput): minutes (non-zero signed integer, required), note (≤10 000, optional), startedAt (optional). Response 201 { "id": "…" }.
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | correction_source (original not in a closed period, or itself a correction), net_negative |
conflict |
409 | today_period_closed |
forbidden |
403 | Not Owner or Admin, or an API token |
POST /api/w/{slug}/time/tickets/{id}/reattribute
Re-point every unbilled, uncorrected entry on a ticket at the ticket's current billing client, after a move. Token: no (browser session only). Owner or Admin. The path takes the ticket UUID. Response 200 { "updated": 3 } with the number of entries changed.