<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[SteadyLatch]]></title><description><![CDATA[SteadyLatch]]></description><link>https://steadylatch.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ac921ecd64837f03ed660dd/a99ff827-c8d8-4dff-8b0a-df5eb6a83186.png</url><title>SteadyLatch</title><link>https://steadylatch.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 23 Oct 2026 22:46:30 GMT</lastBuildDate><atom:link href="https://steadylatch.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Clockify API Pagination to Excel: Read Every Page and Check What Is Missing]]></title><description><![CDATA[A PowerShell 7 loop that stops on the Last-Page header, catches duplicates and running timers, and writes hours per project to an Excel workbook.]]></description><link>https://steadylatch.hashnode.dev/clockify-api-pagination-to-excel-read-every-page-and-check-what-is-missing</link><guid isPermaLink="true">https://steadylatch.hashnode.dev/clockify-api-pagination-to-excel-read-every-page-and-check-what-is-missing</guid><category><![CDATA[Powershell]]></category><category><![CDATA[excel]]></category><category><![CDATA[api]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Andrew R]]></dc:creator><pubDate>Fri, 09 Oct 2026 18:14:25 GMT</pubDate><content:encoded><![CDATA[<p>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. Below is one PowerShell 7 script that does all of it.</p>
<h2>How Clockify pages its lists</h2>
<p>The <a href="https://docs.clockify.me/#section/Pagination">Pagination section</a> of the API documentation sets the rules:</p>
<ul>
<li><code>page</code> starts at 1. The endpoints used here take the page size as <code>page-size</code>, default 50; the reference gives a minimum of 1 and no maximum.</li>
<li>Every paginated response carries a <code>Last-Page</code> header: <code>true</code> on the final page, <code>false</code> when more pages follow.</li>
</ul>
<p>Two common shortcuts get this wrong without throwing any error. 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; the reference does not say whether it caps the size. Stopping when a page comes back empty costs one extra request per list, and that adds up fast on a plan with a request limit. The script below stops only on <code>Last-Page: true</code> and treats a missing header as an error.</p>
<p>Time entries are listed per user (<code>GET /v1/workspaces/{workspaceId}/user/{userId}/time-entries</code>, with <code>start</code> and <code>end</code> in <code>yyyy-MM-ddThh:mm:ssZ</code>), so a workspace report is one paged list of users, one of projects, and one of entries per user.</p>
<h2>The script</h2>
<p>The key goes in the <code>X-Api-Key</code> header (<a href="https://docs.clockify.me/#section/Authentication">Authentication</a>). Keep it out of the script and your history: <code>$env:CLOCKIFY_API_KEY = Read-Host -MaskInput 'Clockify API key'</code>.</p>
<pre><code class="language-powershell">#Requires -Version 7
&lt;#
  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.
#&gt;
[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 {
    &lt;# Every item of a paginated GET. Stops on Last-Page: true; fails if the header is missing. #&gt;
    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 '&amp;'
        $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 {
    &lt;#
      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.
    #&gt;
    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]) { '&lt;c r="{0}"&gt;&lt;v&gt;{1}&lt;/v&gt;&lt;/c&gt;' -f $ref, ([double] $v).ToString('R', [cultureinfo]::InvariantCulture) }
                else { '&lt;c r="{0}" t="inlineStr"&gt;&lt;is&gt;&lt;t&gt;{1}&lt;/t&gt;&lt;/is&gt;&lt;/c&gt;' -f $ref, (&amp; $esc $v) }
            }
            '&lt;row r="{0}"&gt;{1}&lt;/row&gt;' -f $r, ($cells -join '')
        }
        $files["xl/worksheets/sheet$i.xml"] = "&lt;?xml version=`"1.0`" encoding=`"UTF-8`"?&gt;&lt;worksheet $ns&gt;&lt;sheetData&gt;$($rows -join '')&lt;/sheetData&gt;&lt;/worksheet&gt;"
        $sheetTags += '&lt;sheet name="{0}" sheetId="{1}" r:id="rId{1}"/&gt;' -f (&amp; $esc $name), $i
        $sheetRels += '&lt;Relationship Id="rId{0}" Type="{1}/worksheet" Target="worksheets/sheet{0}.xml"/&gt;' -f $i, $rel
        $overrides += '&lt;Override PartName="/xl/worksheets/sheet{0}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/&gt;' -f $i
    }
    $files['[Content_Types].xml'] = '&lt;?xml version="1.0" encoding="UTF-8"?&gt;&lt;Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"&gt;&lt;Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/&gt;&lt;Default Extension="xml" ContentType="application/xml"/&gt;&lt;Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/&gt;' + $overrides + '&lt;/Types&gt;'
    $files['_rels/.rels'] = '&lt;?xml version="1.0" encoding="UTF-8"?&gt;&lt;Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"&gt;&lt;Relationship Id="rId1" Type="' + $rel + '/officeDocument" Target="xl/workbook.xml"/&gt;&lt;/Relationships&gt;'
    $files['xl/workbook.xml'] = "&lt;?xml version=`"1.0`" encoding=`"UTF-8`"?&gt;&lt;workbook $ns xmlns:r=`"$rel`"&gt;&lt;sheets&gt;$sheetTags&lt;/sheets&gt;&lt;/workbook&gt;"
    $files['xl/_rels/workbook.xml.rels'] = '&lt;?xml version="1.0" encoding="UTF-8"?&gt;&lt;Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"&gt;' + $sheetRels + '&lt;/Relationships&gt;'

    $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
</code></pre>
<h2>What it checks</h2>
<ul>
<li><strong>No <code>Last-Page</code> header</strong>, an empty page that says more follow, or more than <code>-MaxPages</code> pages: it stops and writes nothing (exit 2).</li>
<li><strong>Users who left:</strong> the users call passes <code>status=ALL</code>, one of the values the reference lists for that filter, so someone deactivated during the month is not skipped. I did not check what the default returns; passing the filter avoids depending on it.</li>
<li><strong>Archived projects:</strong> with no <code>archived</code> filter the reference says you get both archived and active projects. An entry whose project is still missing is listed as an unknown project.</li>
<li><strong>The same entry on two pages:</strong> pages are cut by position, so an entry added or deleted during the run shifts the list. Duplicates are reported; a skipped entry cannot be seen from the pages alone, so run it when nobody is editing last month, or twice.</li>
<li><strong>Running timers</strong> are not counted and are listed; time without a project goes under “(no project)” and is listed; an entry that ends after the month is counted and listed.</li>
<li><strong>HTTP 429</strong> is retried twice with a growing pause; any other error stops the run. 30-second timeout per request, and the request count is printed.</li>
</ul>
<h2>How it was tested</h2>
<p>Not against a live Clockify workspace, to be clear. I wrote a small local HTTP server that follows the documented contract (<code>page</code>, <code>page-size</code>, <code>Last-Page</code>, <code>X-Api-Key</code>) and serves invented data: two users with 120 entries each. Changing one thing at a time on that server:</p>
<pre><code class="language-text">PS&gt; ./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:
Month    Project           Hours
2026-09  Internal          80
2026-09  Support retainer  80
2026-09  Website redesign  80
</code></pre>
<pre><code class="language-text">PS&gt; ./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:
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
</code></pre>
<pre><code class="language-text">PS&gt; ./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:
duplicate  Ana Example  u1-e50  same entry on two pages: data changed while reading, run again
</code></pre>
<pre><code class="language-text">PS&gt; ./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.
</code></pre>
<h2>Opening it in Excel</h2>
<p>The script writes the .xlsx itself with .NET's ZIP classes: no Excel, no extra module (I ran it on Windows 11 only). The hours are stored as numbers, so Excel shows them with your own decimal separator. I opened the workbook in Microsoft 365 Excel with Spanish regional settings: no repair prompt, hours as numbers (82,25) and SUM working. The workbook is deliberately plain: no widths, formats or styles.</p>
<h2>Limits</h2>
<ul>
<li>Clockify's help says a free workspace can make only 30 API requests per hour (<a href="https://clockify.me/help/troubleshooting/general-api-troubleshooting/how-to-export-data-via-api">Clockify Help</a>). The test above needed 8 requests for 2 users.</li>
<li>The month is taken in UTC: entries just after midnight local time can land in the previous month.</li>
<li>It only trusts what the pages say. Cross-checking against Clockify's summary report is up to you.</li>
</ul>
<hr />
<p>The full guide (exit codes, Excel steps) is on my site: <a href="https://steadylatch.com/guides/clockify-api-pagination-excel">https://steadylatch.com/guides/clockify-api-pagination-excel</a></p>
<p>I'm thinking about a ready-made monthly version: one workspace, an Excel workbook with hours per project and user, and an exceptions sheet. It doesn't exist yet. If you'd use it, here's the proposal: <a href="https://steadylatch.com/offers/clockify-excel-report">https://steadylatch.com/offers/clockify-excel-report</a></p>
]]></content:encoded></item></channel></rss>