HubstaffDeveloper Portal

Guidelines

Time and activity export

This guide shows how to copy time and activity data from Hubstaff to an external database. You can then join this data with data from other tools, for example an HRIS or a CRM. The guide covers two tasks: a daily sync and a large export for a long period.

Requirements
  • Scope: hubstaff:read. All endpoints in this guide are GET endpoints. Refer to Authentication.
  • Permission to see the time of all members. Use a token of an owner or a manager. A token of a member with fewer permissions gets fewer records. The response does not tell you that records are missing.
  • /team_payments (Step 3 only) needs an owner token, or a manager with the "Manage billing" permission. A manager without this permission gets 403. Refer to Find the start of the open period for the procedure without this permission.
  • All durations are in seconds. For example, 8 hours is 28800.

Endpoints in scope

Use these endpoints to get a full record of the work of a member. All paths start with /v2/organizations/<org_id>.

EndpointDataRange per requestOldest data
/activitiesOne row for each 10-minute time slot.7 daysStart of the month, 6 months ago
/activities/updates Time slots that changed in a time range of updated_at. 7 days45 days ago
/activities/daily One row for each member, project, task, and date. Includes idle and break time. 31 days January 1, 3 years ago. For example, 2023-01-01 in 2026. 6 years with the long-term retention add-on.
/activities/daily/updates Daily rows that changed in a time range of updated_at. 31 daysWork dates from one month before today (UTC) only
/time_off_requests Time off requests, with one entry for each day in time_off_request_days. No limitJanuary 1, 3 years ago
/holidaysHolidays of the organization.No limit January 1, 3 years ago. 6 years with the long-term retention add-on.
/work_break_projectsNames and settings of the work break types.No limitNo limit

If a request is out of these limits, the API returns 400. The error message gives the oldest date that you can use.

Regular and daily endpoints

The two groups give the same tracked time at two levels of detail.

  • Regular (/activities) gives one row for each 10-minute time slot. Each row has its own start time, activity counts, and flags such as billable, paid, and time_type. Use it when you need the time of day, or a single slot.
  • Daily (/activities/daily) gives one row for each member, project, task, and date. An 8-hour day on one task is 48 regular rows, but only 1 daily row. Daily rows also have idle, manual, resumed, and work_break totals.
Use daily endpoints for reports
For a report, use the daily endpoints. They need fewer requests, they accept a longer range, and they keep data for a longer time. Use the regular endpoints only when you need data for each time slot.

To get the activity percentage, divide overall by input_tracked. Do not divide by tracked. tracked also includes time with no input tracking, for example manual time.

text
activity_percent = input_tracked > 0 ? round(100 * overall / input_tracked) : null

Field formats

Use the correct column type for each field. This prevents parse errors and time zone errors.

FieldFormatExample
id, user_id, project_id, task_id 64-bit integer. task_id can be null. 2340118822
tracked, overall, keyboard, mouse, input_tracked, idle, manual, work_break, amount_usedInteger, seconds.3600
dateYYYY-MM-DD. The date in the time zone of the organization. 2026-09-21
time_slot, starts_at (regular only) ISO 8601 with an offset. The offset is UTC. On /activities only, you can send the time_zone parameter to get the offset of that time zone. /activities/updates always uses UTC. 2026-09-21T09:10:00Z
created_at, updated_atISO 8601 in UTC, with microseconds.2026-09-21T09:14:03.512044Z
billable, paid, immutable Boolean on regular rows. On daily rows, billable is an integer in seconds. On regular rows, paid is true when the time is included in a team payment (payroll). It does not mean "paid time". true
time_type String: normal, idle, resumed, manual, or unknown. normal
Do not add a daily field to tracked
idle, manual, resumed, billable, and work_break are parts of tracked. They are not additional time. If you add them to tracked, you count the same time two times.

Date filters (date[start], date[stop]) use YYYY-MM-DD, and date[stop] is included. Time filters (time_slot, updated) use ISO 8601, and [stop] is not included. Exception: on /activities/daily/updates, updated[stop] is included. Send times in UTC with Z, for example 2026-09-21T09:00:00Z.

On /time_off_requests and /holidays, a filter applies only when you send both [start] and [stop]. If you send only one of them, the API ignores the filter and returns all records.

Use case 1 — Daily sync

Run this job one time each day. It keeps the external database equal to Hubstaff. Keep the id of each row as the primary key, and write rows with an upsert.

Step 1 — Get the changed rows

Request the daily rows that changed since the last run. Calculate the window like this:

  • updated[stop] = the current time minus 20 minutes. Save this value.
  • updated[start] = the saved updated[stop] of the previous run, minus 1 hour.

The windows overlap by 1 hour. Some changes become visible some minutes after their updated_at time. The overlap makes sure that you get them. The upsert removes the duplicate rows. This example shows a run at 02:00 UTC:

bash
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/activities/daily/updates?updated[start]=2026-09-20T00:40:00Z&updated[stop]=2026-09-21T01:40:00Z&page_limit=500" \
  -H "Authorization: Bearer <access_token>"

The response looks like this:

json
{
  "daily_activities": [
    {
      "id": 2340118822,
      "date": "2026-09-21",
      "user_id": 1001,
      "project_id": 5001,
      "task_id": null,
      "tracked": 28800,
      "input_tracked": 27000,
      "overall": 18900,
      "keyboard": 9000,
      "mouse": 14000,
      "billable": 28800,
      "idle": 600,
      "manual": 1800,
      "resumed": 0,
      "work_break": 1800,
      "created_at": "2026-09-21T09:14:03.512044Z",
      "updated_at": "2026-09-21T17:02:41.104377Z"
    }
  ],
  "pagination": { "next_page_start_id": 2340118823 }
}
  • Do not stop when you get one page. Read all pages. Refer to Rate limits and efficiency.
  • If you need data for each time slot, use /activities/updates with the same parameters.

Step 2 — Read the previous period again

Managers can edit or delete time after the day is complete. /activities/updates does not show deleted time slots. Also, /activities/daily/updates shows only work dates from one month before today. Thus, read the full previous period again at each run. Step 2 replaces the last 14 days at each run. Step 1 is still necessary: it gets the changes to dates that are older than 14 days.

bash
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/activities/daily?date[start]=2026-09-07&date[stop]=2026-09-20&page_limit=500" \
  -H "Authorization: Bearer <access_token>"
  1. Get all daily rows for the period. The period is the last 14 days in this example.
  2. Upsert each row.
  3. Delete the rows in your database for these dates that the response does not include. These are rows that were deleted in Hubstaff.

Use a period that is equal to your edit window. If timesheets lock after approval, use the approval period.

Hubstaff does not delete daily rows. When time is deleted, or moved to a different project or task, the row stays and its tracked becomes 0. /activities/daily does not return rows with tracked equal to 0. /activities/daily/updates returns them. When you get a row with tracked equal to 0, delete it in your database.

Step 3 — Get time off, holidays, and work breaks

Tracked time does not include time off and holidays. Get them from their own endpoints.

bash
# Requests approved since the last run (1 hour overlap)
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_requests?approved_at[start]=2026-10-04T00:40:00Z&approved_at[stop]=2026-10-05T01:40:00Z&page_limit=100" \
  -H "Authorization: Bearer <access_token>"

# All requests in the open period: from the day after the last team payment to the end of the next month
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_requests?starts_at[start]=2026-08-01T00:00:00Z&starts_at[stop]=2026-12-01T00:00:00Z&page_limit=100" \
  -H "Authorization: Bearer <access_token>"

# Holidays in the period
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/holidays?date[start]=2026-09-01&date[stop]=2026-09-30" \
  -H "Authorization: Bearer <access_token>"
  • The response includes requests with all statuses. Keep the requests with status equal to approved.
  • For time off, use time_off_request_days. It has one entry for each date, with amount_used in seconds. A request can start before your period and continue into it. The starts_at filter compares only the start date of the request. Thus, start the filter before the period by the maximum length of a time off request in your organization, for example 30 days.
  • Time off requests have no updated filter. A manager can approve, deny, or edit a request after its dates, until the request is paid. Thus, do two reads at each run:
    • Read the requests with approved_at after the previous run. This gets approvals of old requests.
    • Read all requests with starts_at in the open period. The open period starts after the last team payment, and stops at the end of the next month. Refer to Find the start of the open period. Upsert each request. Delete the requests in your database for this period that the response does not include. This gets denials, edits, and deletions.
    Paid requests cannot change. Thus, the data before the open period stays correct. Refer to Time off sync for more information.
  • A holiday record has a date, a paid flag, and amount_used. paid is true when amount_used is more than 0. If amount_used is 0, the response does not include the field. The record does not have a list of members.
  • Break time is in the work_break field of each daily row. To get the names of break types, use /work_break_projects. The list changes rarely, so keep it in a cache.

Find the start of the open period

Hubstaff pays each member separately. Thus, there is no single payroll date for the organization. Use /team_payments to find the date for each member:

bash
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/team_payments?created[start]=2026-07-01T00:00:00Z&created[stop]=2026-10-06T00:00:00Z&page_limit=100" \
  -H "Authorization: Bearer <access_token>"
  1. On the first run, get the team payments that were created in the last 3 months. On the next runs, get only the payments with created[start] after the previous run.
  2. For each member, find the payments that include the member in details[].user_id. Compare their stop_date with the date that you saved for the member. Save the latest date.
  3. Use only the current members of the organization. If a current member has no saved date, start the open period 90 days ago. Otherwise, start it on the day after the earliest saved date, but not more than 90 days ago.

The /team_payments filter is created. It is not the pay period. Use stop_date in the response to get the pay period.

Without access to team payments
/team_payments needs an owner token, or a manager with the "Manage billing" permission. If your token does not have this access, do not read /team_payments. Start the open period 90 days ago. If payments in your organization can change requests that are older than 90 days, use a longer period.
Overtime is available only after payment
Team payments include overtime: overtime_tracked and overtime_details in each payment detail. The activity endpoints do not give overtime. For time that is not paid yet, get the rules from overtime_policies and apply them to the daily tracked totals in your database. overtime_policies is available only when the organization has the overtime feature. Otherwise, it returns 403 with the code feature_not_available.

Step 4 — Replace the last 31 days each week

An incremental sync can miss rows after a failed run. Do not compare totals to find these errors. If a manager moves 8 hours from one project to a different project, the total for the member and the date does not change. Replace the rows one time each week:

  1. Get all daily rows for the last 31 days from /activities/daily.
  2. Upsert each row.
  3. Delete the rows in your database for these dates that the response does not include.

This is Step 2 with a longer period. It uses the same requests as a comparison, and after it your data for these dates is equal to Hubstaff.

The weekly replacement covers the last 31 days. /activities/daily/updates covers work dates from one month before today. If managers can change older time, for example after a month is closed, read the previous month again with /activities/daily one time each month.

Summary of the job

text
every day:
  stop  = now - 20 min
  start = last_stop - 1 h
  for each page of /activities/daily/updates?updated[start]=start&updated[stop]=stop:
    upsert rows with tracked > 0; delete rows with tracked = 0
  rows = all pages of /activities/daily for the last 14 days
  upsert rows; delete local rows for these dates that are not in rows
  upsert /time_off_requests?approved_at[start]=start&approved_at[stop]=stop
  for each page of /team_payments?created[start]=last_stop:
    for each member in details: saved_stop_date[member] = max(saved_stop_date[member], stop_date)
  open_start = day after min(saved_stop_date for current members)
  if a current member has no saved_stop_date: open_start = today - 90 days
  open_start = max(open_start, today - 90 days)
  (no access to /team_payments: open_start = today - 90 days)
  rows = all pages of /time_off_requests for starts_at from open_start to the end of the next month
  upsert rows; delete local requests in the open period that are not in rows
  sync /holidays
  last_stop = stop

every week:
  rows = all pages of /activities/daily for the last 31 days
  upsert rows; delete local rows for these dates that are not in rows

every month (optional):
  reload the previous month with /activities/daily

Use case 2 — Large exports

Use this procedure for a first load, or for a weekly, two-weekly, or monthly export.

  1. Use /activities/daily. One request can cover up to 31 days. For a period of more than 31 days, divide it into windows of 31 days or less.
  2. To filter by member, task, or project, use user_ids[], task_ids[], or project_ids[].
  3. For one project only, you can also use /v2/projects/<project_id>/activities/daily. On this endpoint, include[] accepts users and tasks only.
  4. Run the windows one after the other. Do not run many windows at the same time.
  5. Save the last completed window. If the job stops, start again from that window.
bash
# September 2026, two members
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/activities/daily?date[start]=2026-09-01&date[stop]=2026-09-30&user_ids[]=1001&user_ids[]=1002&page_limit=500" \
  -H "Authorization: Bearer <access_token>"
Get names with include[]
Add include[]=users, include[]=projects, or include[]=tasks. The response then contains the related records. You do not need one more request for each ID.

Regular rows are available for approximately 6 months only, and one request covers 7 days. For an export of time slots, use windows of 7 days.

Rate limits and efficiency

  • Use the maximum page size. Send page_limit=500. The default is 100.
  • Read all pages. Send pagination.next_page_start_id from the response as page_start_id. Stop when the response has no next_page_start_id. Rows are always sorted by id. If the last page is full, the next request returns an empty page.
  • Use the daily endpoints. They need fewer requests than the regular endpoints for the same period.
  • Keep data in a cache. Members, projects, tasks, and work break types change rarely. Do not request them at each run.
  • Obey Retry-After. If you get 429, wait for the time in the Retry-After header. Then send the same request again with the same page_start_id.
  • Wait for new data. Activity data can arrive up to 20 minutes late. Do not use a window that stops less than 20 minutes before the current time.
bash
# First page
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/activities/daily?date[start]=2026-09-01&date[stop]=2026-09-30&page_limit=500" \
  -H "Authorization: Bearer <access_token>"

# Next page. Use pagination.next_page_start_id from the previous response.
curl -g "https://api.hubstaff.com/v2/organizations/<org_id>/activities/daily?date[start]=2026-09-01&date[stop]=2026-09-30&page_limit=500&page_start_id=<next_page_start_id>" \
  -H "Authorization: Bearer <access_token>"

The limits and the rate headers are in Pagination & rate limits.

Errors

HTTPCause and action
400 The range is too long, the start is too old, or a parameter is not valid. Make the window shorter or move it. Do not send the same request again.
401 The token is expired or revoked. Refresh the token and try one time.
403 The token has no permission for this resource (not_authorized), or the organization does not have the feature (feature_not_available). Send an alert. Do not try again. Exception: if /team_payments returns 403, start the open period 90 days ago and continue the sync.
404 The organization does not exist, is not active, or the user of the token is not a member of it. Do not try again.
429 Too many requests. Wait for the time in Retry-After, then continue from the same page_start_id.

Use error_code in the response body to make decisions. Do not use the message text. Refer to HTTP status codes.

The full schemas are in the activities, time_off_requests, and holidays reference tags.