Pull Clockify time entries into Google Sheets with the API
Written against Clockify's published v1 reference and Google's Apps Script docs — every source is listed at the end of the page: auth header, endpoint choice, pagination, the fields you actually get, and the limits that bite on a Free workspace.
The three IDs you need first
The API key. Avatar → Preferences → Advanced → Manage API keys → Generate. Copy it at once; Clockify shows it one time. If your workspace is on a subdomain, the reference is explicit that the key must be generated from that subdomain. A Clockify key also cannot be made read-only, so admins should generate it from a member-level user.
The workspace and user IDs. No need to dig these out of the URL bar: GET /v1/user returns id (your user ID) alongside activeWorkspace and defaultWorkspace.
const API = 'https://api.clockify.me/api/v1';
// Put the key in Project Settings → Script properties as CLOCKIFY_KEY.
// It never appears in this file, so it never lands in your repo.
function clockify_(url, payload) {
const key = PropertiesService.getScriptProperties().getProperty('CLOCKIFY_KEY');
if (!key) throw new Error('Add CLOCKIFY_KEY in Project Settings > Script properties.');
const options = {
method: payload ? 'post' : 'get',
headers: { 'X-Api-Key': key }, // not Authorization: Bearer
contentType: 'application/json',
muteHttpExceptions: true // so we can read the body of a 4xx
};
if (payload) options.payload = JSON.stringify(payload);
const res = UrlFetchApp.fetch(url, options);
const code = res.getResponseCode();
if (code === 429) {
throw new Error('Rate limited. A Free workspace gets 30 requests per hour.');
}
if (code < 200 || code >= 300) {
throw new Error('Clockify ' + code + ': ' + res.getContentText().slice(0, 300));
}
return { data: JSON.parse(res.getContentText()), headers: res.getAllHeaders() };
}
function whoAmI() {
const me = clockify_(API + '/user').data;
Logger.log('userId: %s activeWorkspace: %s', me.id, me.activeWorkspace);
return me;
}
It is X-Api-Key, not a Bearer token
Where most first attempts fail. Clockify does not use OAuth bearer tokens here; the reference says to include either the 'X-Api-Key' or the 'X-Addon-Token' in the request header
. X-Addon-Token is for CAKE.com Marketplace add-ons. For a script you own it is X-Api-Key, raw key, no prefix.
Two details that cost time. Set muteHttpExceptions: true — without it a 401 throws before you can read the body, which is where Clockify says what is wrong. And check your host: the global one is https://api.clockify.me/api/v1, but a workspace pinned to a data region replaces that host with its own — https://euc1.clockify.me/api/v1 for the EU, and the same shape with use2 (USA), euw2 (UK) or apse2 (AU). A correct key against the wrong host still fails.
reports/detailed or the time-entries endpoint?
Two ways to read time entries, and they are not interchangeable.
| POST /reports/detailed | GET .../user/{userId}/time-entries | |
|---|---|---|
| Host | reports.api.clockify.me | api.clockify.me/api |
| Scope | Whole workspace, all users | One user |
| Free-plan window | 31 days per query, per Clockify's Free-plan page | No plan-specific window documented |
| Paging | detailedFilter.page / pageSize in the body | page / page-size in the query string |
| Shape | { "timeentries": [ … ] } | A bare JSON array |
Reports are boxed in on Free. Clockify's Free-plan page states that the Report date range filter on the Free plan is now capped at a maximum of 1 month (31 days) per query
. Its exportType accepts JSON, CSV, XLSX and PDF, but Clockify states that Export to CSV/Excel is a paid feature available in Basic plan or higher
— so on Free, ask for JSON. Clockify's own example body uses "detailedFilter": { "page": 1, "pageSize": 1000 }, and 1000 is also the documented ceiling for report endpoints.
Importing your own hours? Use the user endpoint. Plain GET, no documented 31-day cap, no separate host. Reach for reports/detailed only when you need every user at once — then chunk into 31-day windows so one code path serves Free and paid.
page, page-size, and knowing when to stop
The endpoint documents page (1-indexed, default 1) and page-size (minimum 1, default 50). That default is the trap: omit the parameter and you silently import 50 entries, then conclude the API is broken. Clockify's help centre gives the ceiling — max 5000 for base and 1000 for report endpoints
— so raise it deliberately instead of leaving it at 50.
Clockify documents a custom Last-Page response header — true means The current page is the final page; no more data is available
. Use it, but not alone: casing varies by client, and reports/detailed is a POST rather than one of the synchronous GET endpoints
that section describes. Stop when the header says stop, or when a page returns shorter than requested — then cap the loop, since an unbounded one burns an hour of quota in seconds.
function fetchEntries_(workspaceId, userId, startIso, endIso) {
const PAGE_SIZE = 200, MAX_PAGES = 25;
const rows = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const url = API + '/workspaces/' + workspaceId + '/user/' + userId + '/time-entries'
+ '?start=' + encodeURIComponent(startIso)
+ '&end=' + encodeURIComponent(endIso)
+ '&page=' + page + '&page-size=' + PAGE_SIZE;
const res = clockify_(url);
const batch = res.data;
rows.push.apply(rows, batch);
const h = res.headers;
const last = String(h['Last-Page'] || h['last-page'] || '').toLowerCase();
if (last === 'true' || batch.length < PAGE_SIZE) break; // either signal ends it
}
return rows;
}
The fields you get back, mapped to sheet columns
The response is a bare array. Clockify's sample shows id, description, projectId, taskId, tagIds, userId, workspaceId, billable, isLocked, type, customFieldValues, hourlyRate, costRate, and timeInterval with start, end, duration.
| Column | Comes from | Watch out for |
|---|---|---|
| Date | timeInterval.start | Documented yyyy-MM-ddThh:mm:ssZ — UTC. |
| Description | description | Can be empty. Substitute a placeholder. |
| Project | projectId | An ID, not a name. Resolve it yourself. |
| Start | timeInterval.start | Same UTC caveat. |
| End | timeInterval.end | A running timer has none. Handle the null. |
| Hours | timeInterval | Derive from start and end — see below. |
The project name is what catches people. An entry gives you projectId and nothing else. The endpoint accepts a hydrated boolean, but the reference describes it only as a flag to set whether to include additional information on time entries or not
and never documents the resulting shape — an undocumented response is a poor foundation. One extra call is safer: GET /v1/workspaces/{workspaceId}/projects returns id and name. It pages like every other list endpoint, so read all the pages before you trust the map.
function projectNames_(workspaceId) {
const PAGE_SIZE = 200, map = {};
for (let page = 1; page <= 20; page++) {
const url = API + '/workspaces/' + workspaceId + '/projects'
+ '?page=' + page + '&page-size=' + PAGE_SIZE; // paged like every list endpoint
const batch = clockify_(url).data;
batch.forEach(function (p) { map[p.id] = p.name; });
if (batch.length < PAGE_SIZE) break;
}
return map;
}
Computing hours, and the timezone pitfall
Do not build on duration. Clockify's reference is inconsistent about it: the time-entry sample shows "duration": "8000", while other duration-typed fields there are documented as ISO-8601 (for a 7hr work day, input should be PT7H
), and the project sample puts "estimate": "PT1H30M" next to "duration": "60000". A parser guessing between PT2H30M and a seconds count is a bug waiting to happen.
Subtract the timestamps instead: start and end are both documented yyyy-MM-ddThh:mm:ssZ, so the difference is unambiguous.
The timezone pitfall. That trailing Z is UTC. Write the raw string into the sheet and an entry logged at 00:30 in Rome lands on the previous day, skewing every weekly total. Convert once, into the spreadsheet's own zone via getSpreadsheetTimeZone() — not the script's, never the server's.
function importHours() {
const ss = SpreadsheetApp.getActive(); // requires a bound script
const tz = ss.getSpreadsheetTimeZone(); // the sheet's zone, not the script's
const me = whoAmI();
const end = new Date();
const start = new Date(end.getTime() - 30 * 24 * 3600 * 1000);
const iso = function (d) {
return Utilities.formatDate(d, 'UTC', "yyyy-MM-dd'T'HH:mm:ss'Z'");
};
const names = projectNames_(me.activeWorkspace);
const entries = fetchEntries_(me.activeWorkspace, me.id, iso(start), iso(end));
const rows = entries.map(function (e) {
const s = new Date(e.timeInterval.start);
const f = e.timeInterval.end ? new Date(e.timeInterval.end) : null;
const hours = f ? (f.getTime() - s.getTime()) / 3600000 : 0;
return [
Utilities.formatDate(s, tz, 'yyyy-MM-dd'),
e.description || '(no description)',
names[e.projectId] || '(no project)',
Utilities.formatDate(s, tz, 'HH:mm'),
f ? Utilities.formatDate(f, tz, 'HH:mm') : '(running)',
Math.round(hours * 100) / 100
];
});
const sheet = ss.getSheetByName('Hours') || ss.insertSheet('Hours');
sheet.clear();
sheet.getRange(1, 1, 1, 6)
.setValues([['Date','Description','Project','Start','End','Hours']]);
if (rows.length) sheet.getRange(2, 1, rows.length, 6).setValues(rows);
}
SpreadsheetApp.getActive() only works in a bound script — one opened from the sheet via Extensions → Apps Script. Standalone projects get null; open the file by ID instead.
30 per hour on Free, 50 per second on paid
This number decides your architecture. Clockify's API overview: There's a limit of 30 requests per hour per workspace if you're on the Free plan.
Two other official pages repeat it — the API limitations page as Newly created workspaces on the Free plan are restricted to 30 API requests per hour
, the export guide as 30 requests per hour for the free workspace
.
Read per workspace literally — not per API key. A second key, or a colleague running their own copy, does not buy a second bucket. Paid workspaces sit on a different order of magnitude entirely: the same limitations page says Workspaces on any paid plan maintain the standard limit of 50 requests per second
.
So budget. The script above spends one call on /v1/user, one per page of projects, one per page of entries — three requests for a freelancer's month at page-size=200. The naive design is what breaks: one request per day of the range, or one per project to resolve its name, and thirty days becomes thirty requests. Fetch wide, page large, cache the project map, and treat a 429 as a stop signal, not a retry cue.
What comes back empty on a Free workspace
Billable is a paid feature. Clockify's page on the Free plan changes states that you can no longer set hourly rates, assign dollar amounts to projects
, and that toggling entries billable is restricted. So on Free, billable comes back false on every entry — not because your data is wrong, but because the feature that would set it true is not on your plan.
The practical consequences on Free:
billable— uniformlyfalse. Invoice logic keyed on it silently returns zero; the workaround is to keep rates in the sheet.hourlyRate,costRate— in the response sample, but with rates unavailable nothing populates them. Multiply hours by a rate kept in the sheet.- Detailed-report windows — 31 days per request. Where that cap applies, and how to cover a year.
- CSV and XLSX export — Basic or higher, so ask for
JSON. What changed with CSV export. - Report and dashboard date range — one month (31 days) at a time.
None of it stops you reading entries: the API works on Free, which is the whole point.
A daily trigger, and the quotas that bite
Apps Script can run importHours on a schedule without you opening the file. Delete existing triggers for the handler first, or every deploy leaves another copy behind, each spending your Clockify quota.
function installDailyTrigger() {
ScriptApp.getProjectTriggers()
.filter(function (t) { return t.getHandlerFunction() === 'importHours'; })
.forEach(function (t) { ScriptApp.deleteTrigger(t); });
ScriptApp.newTrigger('importHours')
.timeBased()
.atHour(6)
.nearMinute(30)
.everyDays(1)
.inTimezone(SpreadsheetApp.getActive().getSpreadsheetTimeZone())
.create();
}
nearMinute() is documented as the minute the trigger runs plus or minus 15 minutes
, so the schedule is approximate. Import an overlapping window and rewrite the sheet each run rather than appending, so a repeated run cannot double your rows.
Google's quotas: triggers total runtime is 90 min/day on a consumer @gmail.com account, 6 hr/day on Google Workspace — a retry loop that sleeps can reach it. URL Fetch calls are 20,000/day consumer, 100,000/day Workspace, so Clockify's 30 per hour stops you long before Google does. Script runtime is 6 min per execution, and you may install at most 20 triggers per user per script.
On secrets: script properties are readable by anyone with edit access to the project. If the sheet is shared with people who should not have your key, use PropertiesService.getUserProperties(), which scopes it per user.
If you would rather not maintain this
That is roughly 110 lines you now own: paging, the project cache, timezone conversion, trigger hygiene, and whatever Clockify changes next. I built HourSync because I did not want to maintain my own copy — a Google Sheets add-on doing the same import from a sidebar, with project filters, a Summary sheet of hours per project and a daily refresh on Pro. Permissions and pricing are on the home page.
The ready-made alternative: HourSync is available now in Google Workspace Marketplace.
Try HourSync free for 14 daysNo credit card. Your trial starts on the first import. Need help? Write to danibarbers13@gmail.com.
Staying with your own script? One last piece of honesty: the code here is written against Clockify's published v1 reference, linked below, not against a response I can show you. Run it on your own workspace first — and if a field contradicts this page, tell me and I will fix it.
Sources
- Clockify API reference (v1) — auth, hosts,
Last-Page, parameters, response samples. - Clockify API overview — 30 requests/hour per Free workspace.
- Webhook and API limitations — 30/hour on Free, 50/second on paid plans.
- How to retrieve more than 50 results via API — default page size 50, max 5000 base / 1000 report.
- How to export data via API —
reports/detailedbody,pageSize: 1000, CSV/Excel as paid. - Updates to the Clockify Free plan — rates, billable, export formats, report range.
- Apps Script quotas — URL Fetch, trigger and script runtime, triggers per user.