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.
- Scope:
hubstaff:read. All endpoints in this guide areGETendpoints. 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 gets403. 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>.
| Endpoint | Data | Range per request | Oldest data |
|---|---|---|---|
| /activities | One row for each 10-minute time slot. | 7 days | Start of the month, 6 months ago |
| /activities/updates | Time slots that changed in a time range of updated_at. | 7 days | 45 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 days | Work 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 limit | January 1, 3 years ago |
| /holidays | Holidays of the organization. | No limit | January 1, 3 years ago. 6 years with the long-term retention add-on. |
| /work_break_projects | Names and settings of the work break types. | No limit | No 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 asbillable,paid, andtime_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 haveidle,manual,resumed, andwork_breaktotals.
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.
activity_percent = input_tracked > 0 ? round(100 * overall / input_tracked) : nullField formats
Use the correct column type for each field. This prevents parse errors and time zone errors.
| Field | Format | Example |
|---|---|---|
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_used | Integer, seconds. | 3600 |
date | YYYY-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_at | ISO 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 |
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 savedupdated[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:
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:
{
"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/updateswith 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.
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>"- Get all daily rows for the period. The period is the last 14 days in this example.
- Upsert each row.
- 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.
# 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
statusequal toapproved. - For time off, use
time_off_request_days. It has one entry for each date, withamount_usedin seconds. A request can start before your period and continue into it. Thestarts_atfilter 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
updatedfilter. 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_atafter the previous run. This gets approvals of old requests. - Read all requests with
starts_atin 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.
- Read the requests with
- A holiday record has a
date, apaidflag, andamount_used.paidistruewhenamount_usedis more than 0. Ifamount_usedis 0, the response does not include the field. The record does not have a list of members. - Break time is in the
work_breakfield 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:
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>"- 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. - For each member, find the payments that include the member in
details[].user_id. Compare theirstop_datewith the date that you saved for the member. Save the latest date. - 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.
/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_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:
- Get all daily rows for the last 31 days from
/activities/daily. - Upsert each row.
- 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
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/dailyUse case 2 — Large exports
Use this procedure for a first load, or for a weekly, two-weekly, or monthly export.
- 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. - To filter by member, task, or project, use
user_ids[],task_ids[], orproject_ids[]. - For one project only, you can also use
/v2/projects/<project_id>/activities/daily. On this endpoint,include[]acceptsusersandtasksonly. - Run the windows one after the other. Do not run many windows at the same time.
- Save the last completed window. If the job stops, start again from that window.
# 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>"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_idfrom the response aspage_start_id. Stop when the response has nonext_page_start_id. Rows are always sorted byid. 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 get429, wait for the time in theRetry-Afterheader. Then send the same request again with the samepage_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.
# 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
| HTTP | Cause 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.