HourSync About HourSync
Guide

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.

Step one

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.

Code.gs — key storage and one fetch wrapper
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;
}
Authentication

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.

Endpoint choice

reports/detailed or the time-entries endpoint?

Two ways to read time entries, and they are not interchangeable.

Comparison of the two Clockify endpoints for reading time entries
POST /reports/detailedGET .../user/{userId}/time-entries
Hostreports.api.clockify.meapi.clockify.me/api
ScopeWhole workspace, all usersOne user
Free-plan window31 days per query, per Clockify's Free-plan pageNo plan-specific window documented
PagingdetailedFilter.page / pageSize in the bodypage / 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.

Pagination

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.

Code.gs — paged fetch with two stop conditions
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;
}
Response shape

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.

Clockify JSON fields mapped to spreadsheet columns
ColumnComes fromWatch out for
DatetimeInterval.startDocumented yyyy-MM-ddThh:mm:ssZ — UTC.
DescriptiondescriptionCan be empty. Substitute a placeholder.
ProjectprojectIdAn ID, not a name. Resolve it yourself.
StarttimeInterval.startSame UTC caveat.
EndtimeInterval.endA running timer has none. Handle the null.
HourstimeIntervalDerive 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.

Code.gs — resolving project IDs to names
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;
}
The maths

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.

Code.gs — the import, hours included
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.

Rate limits

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.

Plan differences

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:

None of it stops you reading entries: the API works on Free, which is the whole point.

Automation

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.

Code.gs — install a once-a-day trigger
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.

The alternative

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 days

No 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.

References

Sources

Try it free for 14 days