Guides · APIs to Excel
Clockify API Pagination to Excel: Read Every Page and Check What Is Missing
Published · PowerShell 7.6.6 on Windows 11 · Run against a local mock of the documented API, not a live workspace; workbook opened in Microsoft 365 Excel (see how it was tested)
Getting the first 50 time entries out of Clockify takes one request. Getting all of them for a month, for every user, and knowing that none were lost on the way, takes a loop that knows when to stop and a few checks most scripts leave out. This guide builds that in one PowerShell 7 script and writes the hours per project to an Excel workbook, with no module to install.
Before you start
- PowerShell 7 (
pwsh) on Windows, macOS or Linux. The script uses-SkipHttpErrorCheck, which Windows PowerShell 5.1 does not have. - A Clockify API key, sent in the
X-Api-Keyheader (Clockify API: Authentication). If your workspace is on a subdomain, the documentation says you need a key generated for that workspace. Keep the key out of the script and out of your shell history:$env:CLOCKIFY_API_KEY = Read-Host -MaskInput 'Clockify API key'. - Your workspace ID, from the documented Get all my workspaces endpoint:
Invoke-RestMethod https://api.clockify.me/api/v1/workspaces -Headers @{ 'X-Api-Key' = $env:CLOCKIFY_API_KEY } | Select-Object id, name. - The right base URL. Workspaces in other data regions use a regional prefix, listed under API URLs in the documentation. The script takes it as
-BaseUrl.
How Clockify pages its lists
The Pagination section of the API documentation sets the rules this script relies on:
pagestarts at 1. The endpoints used here take the page size aspage-size, with a default of 50; the reference gives a minimum of 1 and no maximum, so the script stays at 50 unless you raise it.- Every paginated response carries a
Last-Pageheader:trueon the final page,falsewhen more pages follow.
Two shortcuts break this quietly. Stopping when a page comes back shorter than the page size you asked for loses data the day the server sends fewer items per page than requested, for example because it caps the page size; the reference does not say whether it does. Stopping when a page comes back empty costs one extra request per list, which matters on a plan with a request limit. The script stops only on Last-Page: true, and treats a missing header as an error rather than a guess.
Time entries are listed per user (GET /v1/workspaces/{workspaceId}/user/{userId}/time-entries, with start and end in yyyy-MM-ddThh:mm:ssZ), so a workspace report is one paged list of users, one of projects, and one of entries for each user.
The script
Save it as Export-ClockifyMonth.ps1. It reads the key from $env:CLOCKIFY_API_KEY, never prints it, and writes one .xlsx with two sheets: Hours and Exceptions.
#Requires -Version 7
<#
Hours per project for one month, from the Clockify REST API to an Excel workbook (.xlsx).
Reads every page (Last-Page header), checks that nothing was lost on the way and
writes the exceptions on their own sheet. The API key comes from $env:CLOCKIFY_API_KEY.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $WorkspaceId,
[Parameter(Mandatory)] [ValidatePattern('^\d{4}-\d{2}$')] [string] $Month,
[string] $BaseUrl = 'https://api.clockify.me/api/v1',
[ValidateRange(1, 1000)] [int] $PageSize = 50,
[ValidateRange(1, 10000)] [int] $MaxPages = 500,
[string] $OutputFolder = '.'
)
$ErrorActionPreference = 'Stop'
$apiKey = $env:CLOCKIFY_API_KEY
if (-not $apiKey) { Write-Output 'NO KEY: set $env:CLOCKIFY_API_KEY first.'; exit 3 }
$headers = @{ 'X-Api-Key' = $apiKey }
$script:Requests = 0
function Invoke-ClockifyPage {
param([string] $Uri)
for ($attempt = 1; ; $attempt++) {
$script:Requests++
try {
$response = Invoke-WebRequest -Uri $Uri -Headers $headers -TimeoutSec 30 -SkipHttpErrorCheck
}
catch {
if ($attempt -ge 3) { throw "network error after $attempt attempts: $($_.Exception.Message)" }
Start-Sleep -Seconds (2 * $attempt); continue
}
$status = [int] $response.StatusCode
if ($status -eq 200) { return $response }
if ($status -eq 429 -and $attempt -lt 3) { Start-Sleep -Seconds (5 * $attempt); continue }
throw "HTTP $status from $($Uri -replace '\?.*$', '')"
}
}
function Get-ClockifyAll {
<# Every item of a paginated GET. Stops on Last-Page: true; fails if the header is missing. #>
param([string] $Path, [hashtable] $Query = @{})
$items = [System.Collections.Generic.List[object]]::new()
for ($page = 1; $page -le $MaxPages; $page++) {
$q = $Query.Clone(); $q['page'] = $page; $q['page-size'] = $PageSize
$qs = ($q.GetEnumerator() | Sort-Object Key |
ForEach-Object { '{0}={1}' -f $_.Key, [uri]::EscapeDataString("$($_.Value)") }) -join '&'
$response = Invoke-ClockifyPage "$BaseUrl$Path`?$qs"
$batch = @($response.Content | ConvertFrom-Json)
foreach ($item in $batch) { $items.Add($item) }
$last = "$($response.Headers['Last-Page'])"
if ($last -eq 'true') { return $items }
if ($last -ne 'false') { throw "page $page of $Path came without a Last-Page header: cannot tell if data is missing" }
if ($batch.Count -eq 0) { throw "page $page of $Path is empty but Last-Page is false" }
}
throw "more than $MaxPages pages of $Path (raise -MaxPages if that is expected)"
}
function Write-Xlsx {
<#
A minimal .xlsx (Office Open XML) written with .NET only: one sheet per entry of $Sheets
(name → rows, first row = header). Numbers are stored as numbers, so Excel never reads
them as text, whatever the regional settings. Written to a temp file, then moved.
#>
param([string] $Path, [System.Collections.Specialized.OrderedDictionary] $Sheets)
$esc = { param($s) [Security.SecurityElement]::Escape("$s") }
$ns = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
$rel = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
$files = [ordered]@{}
$i = 0; $sheetTags = ''; $sheetRels = ''; $overrides = ''
foreach ($name in $Sheets.Keys) {
$i++
$data = @($Sheets[$name])
$rows = for ($r = 1; $r -le $data.Count; $r++) {
$row = @($data[$r - 1])
$cells = for ($c = 0; $c -lt $row.Count; $c++) {
$ref = '{0}{1}' -f [char](65 + $c), $r
$v = $row[$c]
if ($v -is [double] -or $v -is [int]) { '<c r="{0}"><v>{1}</v></c>' -f $ref, ([double] $v).ToString('R', [cultureinfo]::InvariantCulture) }
else { '<c r="{0}" t="inlineStr"><is><t>{1}</t></is></c>' -f $ref, (& $esc $v) }
}
'<row r="{0}">{1}</row>' -f $r, ($cells -join '')
}
$files["xl/worksheets/sheet$i.xml"] = "<?xml version=`"1.0`" encoding=`"UTF-8`"?><worksheet $ns><sheetData>$($rows -join '')</sheetData></worksheet>"
$sheetTags += '<sheet name="{0}" sheetId="{1}" r:id="rId{1}"/>' -f (& $esc $name), $i
$sheetRels += '<Relationship Id="rId{0}" Type="{1}/worksheet" Target="worksheets/sheet{0}.xml"/>' -f $i, $rel
$overrides += '<Override PartName="/xl/worksheets/sheet{0}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' -f $i
}
$files['[Content_Types].xml'] = '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' + $overrides + '</Types>'
$files['_rels/.rels'] = '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="' + $rel + '/officeDocument" Target="xl/workbook.xml"/></Relationships>'
$files['xl/workbook.xml'] = "<?xml version=`"1.0`" encoding=`"UTF-8`"?><workbook $ns xmlns:r=`"$rel`"><sheets>$sheetTags</sheets></workbook>"
$files['xl/_rels/workbook.xml.rels'] = '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' + $sheetRels + '</Relationships>'
$tmp = "$Path.tmp"
$zip = [IO.Compression.ZipFile]::Open($tmp, 'Create')
try {
foreach ($entry in $files.Keys) {
$writer = [IO.StreamWriter]::new($zip.CreateEntry($entry).Open(), [Text.UTF8Encoding]::new($false))
try { $writer.Write($files[$entry]) } finally { $writer.Dispose() }
}
}
finally { $zip.Dispose() }
Move-Item -LiteralPath $tmp -Destination $Path -Force
}
# Month window in UTC: Clockify filters with yyyy-MM-ddThh:mm:ssZ dates.
$from = [datetime]::ParseExact("$Month-01", 'yyyy-MM-dd', [cultureinfo]::InvariantCulture)
$to = $from.AddMonths(1)
$range = @{ start = $from.ToString('yyyy-MM-ddT00:00:00Z'); end = $to.ToString('yyyy-MM-ddT00:00:00Z') }
try {
$users = Get-ClockifyAll "/workspaces/$WorkspaceId/users" @{ status = 'ALL' }
$projects = Get-ClockifyAll "/workspaces/$WorkspaceId/projects"
$projectNames = @{}
foreach ($p in $projects) { $projectNames[$p.id] = $p.name }
$entries = [System.Collections.Generic.List[object]]::new()
$exceptions = [System.Collections.Generic.List[object]]::new()
foreach ($user in $users) {
$seen = @{}
foreach ($e in Get-ClockifyAll "/workspaces/$WorkspaceId/user/$($user.id)/time-entries" $range) {
if ($seen.ContainsKey($e.id)) {
$exceptions.Add([pscustomobject]@{ Kind = 'duplicate'; User = $user.name; EntryId = $e.id; Detail = 'same entry on two pages: data changed while reading, run again' })
continue
}
$seen[$e.id] = $true
$e | Add-Member -NotePropertyName userName -NotePropertyValue $user.name
$entries.Add($e)
}
}
}
catch {
Write-Output "FAILED: $($_.Exception.Message). Nothing written."
exit 2
}
$hours = @{}
foreach ($e in $entries) {
$start = $e.timeInterval.start; $end = $e.timeInterval.end
if (-not $end) {
$exceptions.Add([pscustomobject]@{ Kind = 'running'; User = $e.userName; EntryId = $e.id; Detail = 'timer still running: not counted' }); continue
}
if (-not $e.projectId) {
$exceptions.Add([pscustomobject]@{ Kind = 'no project'; User = $e.userName; EntryId = $e.id; Detail = 'counted under (no project)' })
}
elseif (-not $projectNames.ContainsKey($e.projectId)) {
$exceptions.Add([pscustomobject]@{ Kind = 'unknown project'; User = $e.userName; EntryId = $e.id; Detail = "project $($e.projectId) not in the project list" })
}
$s = [datetimeoffset]$start; $f = [datetimeoffset]$end
if ($f -le $s) {
$exceptions.Add([pscustomobject]@{ Kind = 'bad interval'; User = $e.userName; EntryId = $e.id; Detail = 'end is not after start: not counted' }); continue
}
if ($f.UtcDateTime -gt $to) {
$exceptions.Add([pscustomobject]@{ Kind = 'crosses month end'; User = $e.userName; EntryId = $e.id; Detail = 'counted in full in this month' })
}
$name = if ($e.projectId -and $projectNames.ContainsKey($e.projectId)) { $projectNames[$e.projectId] } elseif ($e.projectId) { $e.projectId } else { '(no project)' }
$hours[$name] = $hours[$name] + ($f - $s).TotalHours
}
$hoursSheet = [System.Collections.Generic.List[object]]::new()
$hoursSheet.Add(@('Month', 'Project', 'Hours'))
foreach ($p in $hours.GetEnumerator() | Sort-Object Key) { $hoursSheet.Add(@($Month, $p.Key, [math]::Round($p.Value, 2))) }
$exceptionsSheet = [System.Collections.Generic.List[object]]::new()
$exceptionsSheet.Add(@('Kind', 'User', 'Entry ID', 'Detail'))
foreach ($x in $exceptions) { $exceptionsSheet.Add(@($x.Kind, $x.User, $x.EntryId, $x.Detail)) }
$reportPath = Join-Path $OutputFolder "clockify-$Month.xlsx"
Write-Xlsx -Path $reportPath -Sheets ([ordered]@{ Hours = $hoursSheet.ToArray(); Exceptions = $exceptionsSheet.ToArray() })
Write-Output ('{0} users, {1} entries, {2} report rows, {3} requests' -f @($users).Count, $entries.Count, ($hoursSheet.Count - 1), $script:Requests)
if ($exceptions.Count -gt 0) {
Write-Output ("CHECK: {0} exception(s), see the Exceptions sheet of {1}" -f $exceptions.Count, $reportPath)
exit 1
}
Write-Output "OK: $reportPath"
exit 0
What it checks, and why
- No
Last-Pageheader - The script cannot tell whether data is missing, so it stops and writes nothing (exit 2). The same if a page is empty while
Last-Pagesays more follow, or if a list runs past-MaxPages. - Users who left
- The users call passes
status=ALL, one of the values the reference lists for that filter, so that someone deactivated during the month is not skipped together with their hours. We did not check what the default returns; passing the filter avoids depending on it. - Archived projects
- The projects call sends no
archivedfilter: the reference says that, if it is omitted, you get both archived and non-archived projects. An entry whose project is still not in the list is reported as an unknown project. - The same entry on two pages
- Pages are cut by position. If someone adds or deletes an entry while the script reads, the list shifts: one entry can appear on two pages, or one can fall between them. The script reports duplicates and asks for another run. A skipped entry cannot be seen from the pages alone; see Limits.
- Running timers
- An entry with a start and no end is not counted, and is listed.
- No project, bad interval, month end
- Time without a project is counted under “(no project)” and listed; an end that is not after the start is not counted; an entry that ends after the month is counted and listed so you can decide where it belongs.
- Too many requests
- An HTTP 429 is retried twice with a growing pause; any other error status stops the run. Every request has a 30-second timeout, and the script prints how many requests it made.
Run it, and how it was tested
PS> $env:CLOCKIFY_API_KEY = Read-Host -MaskInput 'Clockify API key'
PS> ./Export-ClockifyMonth.ps1 -WorkspaceId <your workspace id> -Month 2026-09
We have not run this against a live Clockify workspace. The outputs below come from a local HTTP server written for this guide that follows the documented contract (page, page-size, Last-Page, the X-Api-Key header) and serves invented data: two users with 120 entries each, so each user's entries take three pages. Each run below changed one thing on that server.
Everything adds up.
PS> ./Export-ClockifyMonth.ps1 -WorkspaceId ws-demo -Month 2026-09 -BaseUrl http://localhost:8765/api/v1
2 users, 240 entries, 3 report rows, 8 requests
OK: .\clockify-2026-09.xlsx
| Month | Project | Hours |
|---|---|---|
| 2026-09 | Internal | 80 |
| 2026-09 | Support retainer | 80 |
| 2026-09 | Website redesign | 80 |
A running timer, an entry without a project and one that crosses the month end.
PS> ./Export-ClockifyMonth.ps1 -WorkspaceId ws-demo -Month 2026-09 -BaseUrl http://localhost:8765/api/v1
2 users, 240 entries, 4 report rows, 8 requests
CHECK: 3 exception(s), see the Exceptions sheet of .\clockify-2026-09.xlsx
| Kind | User | Entry ID | Detail |
|---|---|---|---|
| running | Ben Example | u2-e6 | timer still running: not counted |
| no project | Ben Example | u2-e7 | counted under (no project) |
| crosses month end | Ben Example | u2-e8 | counted in full in this month |
An entry added while the second page was read. The last entry of page 1 comes back again on page 2:
PS> ./Export-ClockifyMonth.ps1 -WorkspaceId ws-demo -Month 2026-09 -BaseUrl http://localhost:8765/api/v1
2 users, 240 entries, 3 report rows, 8 requests
CHECK: 1 exception(s), see the Exceptions sheet of .\clockify-2026-09.xlsx
| Kind | User | Entry ID | Detail |
|---|---|---|---|
| duplicate | Ana Example | u1-e50 | same entry on two pages: data changed while reading, run again |
The server stops sending Last-Page.
PS> ./Export-ClockifyMonth.ps1 -WorkspaceId ws-demo -Month 2026-09 -BaseUrl http://localhost:8765/api/v1
FAILED: page 1 of /workspaces/ws-demo/user/u1/time-entries came without a Last-Page header: cannot tell if data is missing. Nothing written.
A run where the server answered one request with HTTP 429 finished normally with one extra request (9 instead of 8). The exit codes:
0- Report written, no exceptions.
1- Report written, with exceptions to review.
2- Nothing written: an HTTP error, a network failure after three attempts, or pages that cannot be trusted.
3- No API key in
$env:CLOCKIFY_API_KEY.
Open the result in Excel
The script writes the .xlsx itself with .NET's ZIP classes, so it needs no Excel and no extra module. It uses only what PowerShell 7 includes; we ran it on Windows 11 only. The hours are stored as numbers, not text, so Excel shows them with your own decimal separator and they add up. We opened the workbook from the run with exceptions in Microsoft 365 Excel 16.0 (build 20430, 64-bit) with Spanish regional settings: no repair prompt, the hours came back as numbers (82,25), SUM of the Hours column gave 241,5 and ISNUMBER was TRUE. The workbook is deliberately plain: no column widths, number formats or styles.
Limits
- Request limits. The API documentation gives 50 requests per second for add-ons. Clockify's help says a free workspace can make only 30 API requests per hour (Clockify Help). The test run above needed 8 requests for 2 users; with ten users on three pages each it would be over 30. Check the count the script prints.
- An entry that falls between pages is not detected. Run it when nobody is editing last month's time, or run it twice and compare the totals.
- Time zones. The month is taken in UTC. An entry logged just after midnight local time on the first of the month can land in the previous month. Check entries near the boundary if your team is far from UTC.
- The Reports API (
reports/detailed) returns entries for the whole workspace in one paged list, with fewer requests. The documentation says its data on the free plan is limited to an interval of 31 days. This guide does not use it. - It is not a reconciliation. It checks what the pages say about themselves. Comparing the totals with Clockify's own summary report is a separate step.
The code on this page was written for this guide from the public Clockify API documentation. The data in the outputs is invented. Clockify is a trademark of its owner; SteadyLatch is not affiliated with it.