A period is a calendar month or a Monday-based week in the workspace timezone. Closing a period is the only lock on time: there is no submit-and-approve workflow. None of the four operations on this page is in the API token allow-list; a token gets 403 forbidden ("This operation is not available to API keys") on all of them, and the MCP close_period tool always refuses. Reports are available to every workspace user in the app; integrations that need report figures read time entries instead.
GET /api/w/{slug}/periods
Periods from a start date onward, created on demand with gaps filled. Token: no (browser session only). Owner or Admin.
Query parameter (PeriodQuery) |
Type | Meaning |
|---|---|---|
from |
YYYY-MM-DD |
Optional start of the range |
Response 200 (PeriodList)
{
"periods": [
{
"id": "01924c3e-0008-7000-8000-000000000008",
"periodStart": "2026-08-01",
"periodEnd": "2026-08-31",
"closedAt": "2026-09-02T07:40:00.000Z",
"closedBy": "01924c3e-0004-7000-8000-000000000001"
},
{
"id": "01924c3e-0008-7000-8000-000000000009",
"periodStart": "2026-09-01",
"periodEnd": "2026-09-30",
"closedAt": null,
"closedBy": null
}
],
"firstPeriodStart": "2026-08-01",
"suggestedFirstPeriodStart": "2026-08-01"
}
firstPeriodStart is null until the first close fixes it; suggestedFirstPeriodStart is the earliest calendar boundary that includes all logged work.
GET /api/w/{slug}/periods/{id}/preview
The pre-close checklist for a period. Token: no (browser session only). Owner or Admin. The checks are advisory; a close is never blocked by them.
Response 200 (ClosePreview)
| Field | Type | Meaning |
|---|---|---|
period |
Period | The period |
firstPeriodStart |
date | The first period start in force, or the suggestion for a first close |
firstClose |
boolean | Whether this would be the workspace's first close |
checks[] |
object | key, available, items[] (label, optional ticketRef, actual, expected) |
checks[].key |
What it lists |
|---|---|
targets |
People below their weekly target |
moved |
Tickets moved or re-attributed after time was logged |
wip |
Older billable work on open on_close tickets |
banks |
Open on_close tickets that resolve to a time bank |
rates |
Entries with no applicable rate |
Errors: forbidden 403, not_found 404.
POST /api/w/{slug}/periods/{id}/close
Close the period. Token: no (browser session only). Owner or Admin with a re-authentication in the last ten minutes. Periods close in order, the close is irreversible, and the first close fixes firstPeriodStart and freezes the workspace timezone and period length.
Body field (CloseInput) |
Type | Required | Meaning |
|---|---|---|---|
confirmation |
string (≤64) | yes | Must equal the period's periodStart |
firstPeriodStart |
YYYY-MM-DD |
first close only | Must be a calendar boundary that does not exclude logged work |
curl -X POST https://saq.no/api/w/acme/periods/01924c3e-0008-7000-8000-000000000009/close \
-H 'Cookie: <browser session>' \
-H 'Content-Type: application/json' \
-d '{ "confirmation": "2026-09-01" }'
Response 200 (Period) with closedAt and closedBy set.
| Error | Status | details.reason |
|---|---|---|
reauth_required |
403 | No recent re-authentication |
forbidden |
403 | Not Owner or Admin, or an API token |
invalid |
400 | confirm_period_start, period_boundary, period_excludes_work, period_start_range |
conflict |
409 | period_already_closed; close_earlier_first (with nextPeriodStart) |
not_found |
404 | Unknown period |
GET /api/w/{slug}/reports
Aggregate time over a range. Token: no (browser session only). Any workspace user; rows only include tickets the caller may see. Budget: 30 requests per minute per identity.
Query parameter (ReportQuery) |
Type | Default | Meaning |
|---|---|---|---|
from, to |
YYYY-MM-DD |
required | Inclusive range, at most 366 days |
groupBy |
person, project, ticket, client, period, category, outcome |
project |
Row grouping; ticket rolls up through visible ancestors |
billable |
all, yes, no |
all |
Filter by the billable flag |
view |
current, closed |
current |
Live facts, or the facts as snapshotted at period close |
wip |
true, false |
false |
Include work in progress on open on_close tickets |
Response 200 (Report)
{
"rows": [
{
"id": "01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7e",
"label": "Website",
"minutes": 1260,
"adjustments": -30,
"billableMinutes": 1140,
"unbilledMinutes": 300,
"estimateMinutes": 1500,
"includesDescendants": false,
"chargedMinutes": 720,
"bankMinutes": 120,
"freeMinutes": 0,
"money": [
{
"currency": "NOK",
"chargedAmount": 1080000,
"notionalAmount": 1260000
}
]
}
],
"totalMinutes": 1260
}
| Field | Meaning |
|---|---|
minutes, adjustments |
Logged minutes and the signed sum of corrections |
billableMinutes, unbilledMinutes |
Billable minutes, and those no billing run has allocated yet |
estimateMinutes |
Estimate for the group, or null |
includesDescendants |
The ticket grouping folded children into this row |
chargedMinutes, bankMinutes, freeMinutes |
Billed minutes by outcome |
money[] |
Per currency: chargedAmount and notionalAmount (every billed minute at its rate) in integer minor units; never summed across currencies |
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | range_limit |
forbidden |
403 | Client user, or an API token |
rate_limited |
429 | More than 30 requests per minute |
There is no report export endpoint; download client timesheets from Documents instead.