HubstaffDeveloper Portal

Guidelines

Time off sync

When time off is managed somewhere else — an HRIS, a payroll system, a spreadsheet — Public API v2 lets you mirror it into Hubstaff instead of entering it twice. This guide covers how the three time off endpoint groups relate, a working end-to-end sync, and the mechanics a recurring job needs.

Requirements
  • Scopes: hubstaff:read for every GET, hubstaff:write to create policies, adjust balances, or create and approve requests — see Authentication.
  • Permission to manage time off in the organization — typically owner or manager. An organization access token authenticates as the member it is assigned to, so it inherits that member's permissions rather than carrying its own scopes.
  • Every duration in these endpoints is in seconds — a standard 8-hour day is 28800.
An under-permissioned token reads as an almost-empty organization

Only the balances endpoints refuse outright. The rest degrade quietly:

  • time_off_balances403, error_code 14705. A hard failure you cannot miss.
  • time_off_requests and time_off_policies200, narrowed to the acting member's own requests and policy memberships with nothing in the response to say so. status=all does not widen it.
  • A single policy fetch — 404, even for a policy that a request the same token can read refers to.

So check that the token sees the whole organization before trusting a run. A narrowed read looks exactly like an organization with almost no time off, and the job reports success.

How the three endpoint groups relate

Time off is modelled as three separate resources. A sync job normally touches all three, but at very different frequencies.

ResourceWhat it holdsSync frequency
time_off_policies The rules — who is covered, whether time off is paid, whether requests need approval, and how the balance accrues. Rarely — only when a policy is added or changed.
time_off_balances One accrued/remaining balance per member per policy per year, in seconds. Every run — this is the value you mirror.
time_off_requests The request object, its per-day breakdown, and the approval workflow. Every run — created and approved from your side.
Let one system own the accruals
The most common arrangement is to keep accrual logic in the external tool and treat Hubstaff as a mirror: create policies with accrual_type: "none" so Hubstaff never accrues on its own, then write the authoritative number in on every run. If both systems accrue, the balances drift apart and neither is trustworthy.

Step 1 — Resolve policies

Balances and requests are both keyed by time_off_policy_id, so a sync job starts by mapping each external policy to a Hubstaff one. Cache that map — it changes rarely. status accepts active (the default), archived, or all.

bash
curl "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_policies?status=active&page_limit=100" \
  -H "Authorization: Bearer <access_token>"
Reads and writes name the accrual fields differently
A policy you read back carries policy_type and policy_config. The create and update bodies call the same two things accrual_type and accrual_policy. The keys inside are identical, so the accrual table below reads back unchanged — but a job that fetches a policy and sends it back has to rename policy_config to accrual_policy, and cannot resend the type at all (PUT has no accrual_type).

Before creating a request, confirm the member is actually on the policy — this endpoint returns only the active policies the given user belongs to, which is exactly the set that request creation will accept.

bash
curl "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_policies/user_policies?user_id=<user_id>" \
  -H "Authorization: Bearer <access_token>"

Creating a policy when a new one appears upstream

name, accrual_type, allow_negative_balances, requires_approval, balance_rolls_over_annually, and paid are required. Set requires_approval: false when approvals already happen upstream — requests you create are then auto-approved on the way in.

bash
curl -X POST https://api.hubstaff.com/v2/organizations/<org_id>/time_off_policies \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "PTO (EU)",
    "accrual_type": "none",
    "allow_negative_balances": false,
    "requires_approval": false,
    "balance_rolls_over_annually": true,
    "paid": true,
    "time_off_policy_user_ids": [1001, 1002]
  }'

If you do let Hubstaff accrue, the required accrual_policy fields depend on accrual_type. These are the request-body names; the same values read back under policy_type and policy_config.

accrual_typeRequired accrual_policy fields
none None. starting_balance is optional.
annualhours_per_year, maximum_to_accrue
joined_datehours_per_year, maximum_to_accrue
hours_workedhours_per, hours_worked, maximum_to_accrue
monthlyaccrual_period and accrual_day, plus hours_per_month when accrual_period is month or hours_per_year when it is year. prorate_on_start is optional.
What you cannot change later
accrual_type and membership_rules are immutable after creation. If a policy uses membership_rules (country or employment type), its membership cannot be modified through the API at all — build sync-managed policies from time_off_policy_user_ids instead. Retiring a policy? Use POST /v2/time_off_policies/<id>/archive; DELETE is rejected once any member has approved or paid requests, or used hours.

Step 2 — Mirror balances

A balance is one row per member per policy per year, with amount in seconds. Read the current state first — year defaults to the current year in the organization's timezone, and include[] accepts users and time_off_policies to avoid a second round of lookups.

bash
curl "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_balances?year=2026&user_ids[]=1001&include[]=users" \
  -H "Authorization: Bearer <access_token>"

Write the authoritative value back with a batch of adjustments. reason is required and is stored with the adjustment — put a run identifier in it so the audit trail points back at your job. A successful call returns 201.

bash
curl -X POST https://api.hubstaff.com/v2/organizations/<org_id>/time_off_balances \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "HRIS sync run 2026-07-27T02:00Z",
    "balance_adjustments": [
      {
        "time_off_policy_id": 512,
        "user_ids": [1001, 1002],
        "amount": 288000,
        "modify_balance": "replace"
      }
    ]
  }'
Use replace, not add
modify_balance is the difference between a job you can safely re-run and one you cannot. "replace" sets the balance to amount, so running the same sync twice is a no-op. "add" is a delta — a retry after a half-failed run double-counts. Mirror with "replace" and keep "add" for genuine one-off corrections.

Members who are not yet assigned to the policy are auto-assigned before the adjustment is applied, so you do not need a separate membership call. apply_accruals (default false) additionally applies the annual accrual to those newly assigned members — leave it off when the external tool owns accruals.

Step 3 — Mirror requests and approvals

A request approved in the external tool becomes a request in Hubstaff. time_off_request_days is the part worth care: it needs one entry for every date from starts_at to stops_at inclusive, each with amount_used in seconds. Days you are excluding — weekends, holidays — still need an entry; give them 0 rather than omitting them. The request below spans Thursday to Monday, so the two weekend days are present at zero:

bash
curl -X POST https://api.hubstaff.com/v2/organizations/<org_id>/time_off_requests \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": 1001,
    "time_off_policy_id": 512,
    "starts_at": "2026-08-06T00:00:00",
    "stops_at": "2026-08-10T23:59:59",
    "message": "Vacation (HRIS ref HR-4821)",
    "all_day": true,
    "exclude_weekends": true,
    "time_off_request_days": [
      { "date": "2026-08-06", "amount_used": 28800 },
      { "date": "2026-08-07", "amount_used": 28800 },
      { "date": "2026-08-08", "amount_used": 0 },
      { "date": "2026-08-09", "amount_used": 0 },
      { "date": "2026-08-10", "amount_used": 28800 }
    ]
  }'
Timestamps are wall-clock in the member's timezone
  • starts_at and stops_at are interpreted in the target user's configured timezone, and any offset in the value is ignored — sending 2026-08-06T00:00:00Z does not force UTC.
  • With all_day: true the stored range covers the member's whole local day, and reads back that way — for example 2026-08-06T00:00:00.000+03:00 to 2026-08-06T23:59:59.999+03:00.
  • The time deducted from the balance is always the sum of amount_used across time_off_request_days — not the wall-clock span — so that is the field to get right.

Getting to approved

There are two paths, and which one you get depends on the policy:

  • Policy with requires_approval: false — the request is approved on creation. Nothing else to do. This is usually what you want when the approval already happened upstream.
  • Policy that requires approval — the request lands as submitted; approve it with a second call.
bash
curl -X PUT https://api.hubstaff.com/v2/time_off_requests/<id>/status \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{ "status": "approved", "remove_shifts": true }'

Denials mirror the same way, and response is required when denying:

bash
curl -X PUT https://api.hubstaff.com/v2/time_off_requests/<id>/status \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "denied",
    "response": "Withdrawn in the HRIS"
  }'

Allowed transitions are submitted → approved, submitted → denied, approved → denied, and denied → approved, so a decision reversed upstream can be reversed here too. Paid and partially paid requests are frozen — the status endpoint rejects them.

remove_shifts belongs on the approval
On POST, remove_shifts is honored only when the policy does not require approval. For any policy that does require approval it is silently ignored on create — pass it on the status call instead, where it clears attendance shifts that overlap the approved range.
Editing and deleting are restricted
PUT /v2/time_off_requests/<id> is a full replacement — every field, including the complete time_off_request_days array, must be sent again. Approved, paid, and partially paid requests cannot be edited or deleted at all. Editing a denied request silently resubmits it (status returns to submitted), and if the policy does not require approval it is auto-approved on update.

Read back and reconcile

The same endpoints run in reverse when the external system needs Hubstaff's view — for payroll export, or just to confirm the mirror converged. Sideload with include[] so you are not issuing a lookup per row.

bash
curl "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_requests?approved_at[start]=2026-07-01T00:00:00Z&approved_at[stop]=2026-08-01T00:00:00Z&include[]=users&include[]=time_off_policies&page_limit=100" \
  -H "Authorization: Bearer <access_token>"

Then re-read balances for the same members and compare against the source of truth. If they disagree, the fix is another "replace" adjustment — not an "add" of the difference.

Checking one request's balance impact

Fetching a single request returns a balance_preview alongside it — a sibling key of time_off_request, not a field inside it. It is the quickest way to confirm one request landed against the balance you expected, and it works even for a token that cannot read the balances collection.

bash
curl https://api.hubstaff.com/v2/time_off_requests/<id> \
  -H "Authorization: Bearer <access_token>"

# {
#   "time_off_request": { "id": 4310, "amount_used": 86400, ... },
#   "balance_preview": {
#     "starting_balance": { "current": 288000 },
#     "pending_approval": { "current": 0 },
#     "amount_left": { "current": 201600 },
#     "amount_used": { "current": 86400 },
#     "holiday_hours": { "current": 0 }
#   }
# }

All five figures are in seconds, matching the rest of the time off API.

There is no external ID field
Time off records carry no place to store your system's primary key. Match on user_id + time_off_policy_id + date range, and write a stable reference into message (for requests) or reason (for balance adjustments) so a human investigating a discrepancy can trace it back. Keep your own mapping table for anything stronger than that.

Run it on a schedule

Time off has no webhooks
Hubstaff does not emit webhook events for time off policies, balances, or requests — see Webhooks for the events that do exist. A time off integration has to poll.

Requests can be filtered by created[start] / created[stop], starts_at[start] / starts_at[stop], and approved_at[start] / approved_at[stop] — all ISO 8601, with the [stop] bound exclusive. There is no updated_at filter, so a strict "everything that changed since last run" query is not possible — records do carry created_at and updated_at, you just cannot query on the latter. In practice:

  • Poll created[start] from your last watermark to pick up new requests, and approved_at[start] to pick up newly approved ones.
  • Overlap the window (re-scan the last day or two) so a record created just after your previous query's cutoff is not missed.
  • Re-scan an outer window — the current and next month by starts_at — periodically to catch edits and deletions, which no filter surfaces.

Every list endpoint here is cursor-paginated. Send page_limit, then feed the response's pagination.next_page_start_id back in as page_start_id until it stops coming back.

bash
# First page
curl "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_requests?page_limit=100" \
  -H "Authorization: Bearer <access_token>"

# Next page — pass pagination.next_page_start_id from the previous response
curl "https://api.hubstaff.com/v2/organizations/<org_id>/time_off_requests?page_limit=100&page_start_id=<next_page_start_id>" \
  -H "Authorization: Bearer <access_token>"

Rate limits are per access token and a nightly sync can hit them while paging. Back off on 429 using the Retry-After header rather than a fixed sleep — the exact limits and headers are documented under Pagination & rate limits.

Make the job re-runnable

Assume every run can be interrupted and repeated — a timeout mid-page, a 429, a deploy. Three rules make a repeat run safe:

  1. Mirror balances with "replace" — it sets the balance to amount, so a second run writes the same number instead of adding to it.
  2. Check before creating a request — query the member's existing requests for that date range and skip if one already matches. There is no idempotency key, and nothing stops a duplicate from being created.
  3. Write balances after requests — approving a request deducts from the balance, so applying the authoritative number last leaves it correct either way.

Errors

Failures come back as a JSON body with error (human-readable), error_code (numeric, stable), and sometimes details. Branch on error_code, never on the message text.

HTTPWhat it means for a sync job
400 Invalid parameters — for example a missing day in time_off_request_days, or an accrual_policy field missing for the chosen accrual_type. Do not retry unchanged.
401Token expired or revoked. Refresh and retry once.
403 The organization is not on an active plan, or the acting member lacks permission — from these endpoints, in practice only the balances ones (error_code 14705). Alert rather than retry.
404 Unknown or non-visible ID — often a stale cached time_off_policy_id after a policy was archived. Refresh the policy map. A request you can read may also reference a policy your token cannot fetch, in which case refreshing will not help and the token is the problem.
429 Rate limited. Honor Retry-After and resume from the same page_start_id.

error_code values are grouped in ranges — 10000–10999 auth, 11000–11999 validation, 12000–12999 resource, 13000–13999 rate limiting, 14000–14999 time and activity, 15000–15999 system. Time off permission failures arrive in the 14000 band (for example 14705 — no permission to manage time off balances). The full list is available from GET /v2/error_codes and under the errors reference tag; the HTTP semantics are covered in HTTP status codes.

Full operation schemas live under the time_off_policies, time_off_balances, and time_off_requests reference tags.