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

How Clockify pages its lists

The Pagination section of the API documentation sets the rules this script relies on:

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-Page header
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-Page says 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 archived filter: 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
Sheet “Hours”
MonthProjectHours
2026-09Internal80
2026-09Support retainer80
2026-09Website redesign80

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
Sheet “Exceptions”
KindUserEntry IDDetail
runningBen Exampleu2-e6timer still running: not counted
no projectBen Exampleu2-e7counted under (no project)
crosses month endBen Exampleu2-e8counted 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
Sheet “Exceptions”
KindUserEntry IDDetail
duplicateAna Exampleu1-e50same 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

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.