Getting started
Authentication
Hubstaff APIs accept three kinds of credentials. Pick the one that fits how your integration runs:
- Personal access tokens (PAT) — for server-side scripts, CI jobs, internal tooling. Scoped to a single user. No browser flow.
- OAuth applications — for multi-tenant integrations where end users sign in with their Hubstaff account. Standard Authorization Code + PKCE flow.
- Organization access tokens — long-lived
hsoat_bearer tokens for organization-wide server-side automation. Created by an admin in the Hubstaff app and run as a chosen member. No browser flow, no token exchange.
Personal access tokens and OAuth apps are built on OpenID Connect over OAuth 2.0 — both issue JWT access tokens from the same token endpoint, signed with RSA (verify against jwks_uri in the discovery document). The JWT payload carries iat, exp, and the granted scope string. Organization access tokens skip that exchange entirely — the hsoat_ secret is sent directly as the bearer.
Endpoints
| Endpoint | URL |
|---|---|
| OIDC Discovery | https://account.hubstaff.com/.well-known/openid-configuration |
| Authorize | https://account.hubstaff.com/authorizations/new |
| Token (issue & refresh) | https://account.hubstaff.com/access_tokens |
| User info | https://account.hubstaff.com/user_info |
| JWKS | https://account.hubstaff.com/jwks.json |
Use the discovery document at runtime; cache it for up to 1 week. All other URLs above are derived from it.
Personal access tokens
Create a PAT in Account → Personal access tokens. Pick the scopes you need (see Scopes). On creation you receive a single string — this is a refresh token, not an access token. Store it securely; we can only show it once.
Step 1 — Exchange the refresh token for an access token
Before your first API request, swap the PAT refresh token for a short-lived access token. PATs do not have a client_id or client_secret — do not send credentials; the request is rejected if you do.
curl -X POST https://account.hubstaff.com/access_tokens \
-d grant_type=refresh_token \
-d refresh_token=<your_pat_refresh_token>Successful response:
{
"token_type": "bearer",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 86400,
"refresh_token": "eyJhbGciOiJSUzI1NiIs..."
}refresh_token JWT string. The old string stops being accepted because its exp claim changes. Replace what you have on disk on every refresh, or your next refresh will fail. Step 2 — Call the API
Send the access token in the Authorization header on every request:
Authorization: Bearer eyJhbGciOiJSUzI1NiIs... If the token is bound to a DPoP key (see DPoP), also send a fresh DPoP header on every request.
Step 3 — Refresh when the access token expires
When you receive 401 or the expires_in window is close, call the same endpoint again with the current refresh token — exactly the same request as Step 1.
Token lifetimes
| Token | Default lifetime | Notes |
|---|---|---|
| access_token | 24 hours | Short-lived. Use it until expires_in runs out, then refresh. |
| refresh_token | 90 days | Sliding window — every successful refresh resets expiry to 90 days from now. |
OAuth applications
Create your app in Account → OAuth apps to get a client_id and client_secret. Scopes are requested per authorize call (Step 1 below), not pre-registered.
Step 1 — Build the authorize URL and redirect the user
Generate a unique nonce per request, URL-encode each parameter, then send the user's browser to the authorize URL via HTTP 302 redirect (or a plain <a href>). Example URL shape — line-wrapped for readability, but in practice it's one continuous URL:
https://account.hubstaff.com/authorizations/new
?client_id=<your_client_id>
&response_type=code
&redirect_uri=<url_encoded_redirect_uri>
&scope=openid+profile+email+hubstaff:read
&state=<opaque_csrf_value>
&nonce=<unique_per_request>
&code_challenge=<derived_challenge>
&code_challenge_method=S256 Your backend builds this URL and redirects the user there. Hubstaff shows the login and consent screens, then redirects back to your redirect_uri with ?code=...&state=....
code_verifier (43–128 URL-safe characters: A-Z a-z 0-9 - . _ ~) and derive the challenge: code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) # no '=' padding PKCE adds defence-in-depth against authorization-code interception, and is required by OAuth 2.1. Skip code_challenge / code_challenge_method here (and code_verifier in Step 2) if you choose not to use it.
| Param | Required | Description |
|---|---|---|
| client_id | yes | From the OAuth app registration. |
| response_type | yes | Must be code. |
| redirect_uri | yes | Must exactly match a redirect URI registered on the app. |
| scope | yes | Space-separated list. See Scopes below. |
| nonce | yes | Unique per request. Prevents replay; echoed back in the id_token. |
| code_challenge | optional | PKCE S256 transformation of the verifier. Strongly recommended; effectively required if you can't store client_secret securely (mobile / SPA). |
| code_challenge_method | optional | Required only if code_challenge is sent. Use S256. |
| state | recommended | Opaque CSRF token; we echo it back on the redirect. |
The user sees a consent screen listing your app and the requested scopes. After they approve, we redirect to redirect_uri with ?code=<...>&state=<...>.
Step 2 — Exchange the code for tokens
Validate state matches what you sent. Then POST the code to the token endpoint. Authenticate with HTTP Basic using client_id / client_secret (or send them in the body as client_secret_post). If you set code_challenge in Step 1, also send the matching code_verifier; otherwise omit it.
curl -X POST https://account.hubstaff.com/access_tokens \
-u <client_id>:<client_secret> \
-d grant_type=authorization_code \
-d code=<received_code> \
-d redirect_uri=<same_redirect_uri> \
-d code_verifier=<original_verifier> # optional, only if PKCE was used at /authorizeSuccessful response:
{
"token_type": "bearer",
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 86400,
"refresh_token": "eyJhbGciOiJSUzI1NiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs..."
}id_token is only returned if openid was in the requested scopes.
Step 3 — Refresh the access token
Same endpoint, different grant. Send your client credentials and the current refresh token. Optionally pass scope to narrow the granted scopes — you cannot broaden them past the original grant.
curl -X POST https://account.hubstaff.com/access_tokens \
-u <client_id>:<client_secret> \
-d grant_type=refresh_token \
-d refresh_token=<your_refresh_token> The response shape matches Step 2 (minus id_token). The returned refresh_token string is rotated — replace what you have on disk.
Token lifetimes
| Token | Default lifetime | Notes |
|---|---|---|
| access_token | 24 hours | Short-lived. Use it until expires_in runs out, then refresh. |
| refresh_token | 30 days | Sliding window — every successful refresh resets expiry to 30 days from now. |
| id_token | 5 minutes | Use it once to identify the user; do not store long-term. |
Rate limits & client best practices
400 with {"error":"rate_limit"}. Cache the access_token and only refresh when it actually expires (the expires_in window is up, or the API returns 401). A workable refresh strategy:
- Store
access_token,refresh_token, and the absolute expiry (e.g.now + expires_in - 60s). - Reuse the cached
access_tokenon every API call until that expiry passes — typically thousands of requests per refresh. - On
401from the API, refresh once and retry the original request. Do not loop on repeated 401s. - Always persist the new
refresh_tokenreturned by the refresh call. Reusing the previous string will fail.
Per-API rate limits (for actual data requests) are documented under Pagination & rate limits.
Scopes
Request the narrowest set of scopes you actually need. On refresh you may pass a scope parameter to narrow further; you cannot broaden past the original grant.
| Scope | Grants |
|---|---|
| openid | Required to receive an id_token. |
| profile | User name and basic profile fields. |
| User email address. | |
| hubstaff:read | Read access to the Hubstaff V2 API. |
| hubstaff:write | Write access to the Hubstaff V2 API. |
| tasks:read | Read access to the Tasks API. |
| tasks:write | Write access to the Tasks API. |
DPoP (Proof-of-Possession)
Hubstaff supports DPoP (RFC 9449) to bind a token to a client-held key pair. If a token leaks, an attacker who doesn't have the private key cannot replay it. Once a token is DPoP-bound, every request — including refresh — requires a fresh signed DPoP header alongside Authorization.
Generate an EC P-256 key pair (one time)
openssl ecparam -name prime256v1 -genkey -out dpop_private.pem
openssl ec -in dpop_private.pem -pubout -out dpop_public.pemBuild the DPoP JWT
JWT header:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { <your public key as JWK> }
}Required claims:
| Claim | Value |
|---|---|
| htm | Uppercase HTTP method — "GET", "POST", etc. |
| htu | Full target URI: scheme + host + path. No query string, no fragment. |
| iat | Unix timestamp now. Accepted within roughly ±1 minute of server time. |
| jti | Unique per JWT, max 128 bytes. Reused values are rejected (replay protection). |
| ath | BASE64URL(SHA256(access_token)), no padding. Required whenever you're also sending Authorization: Bearer ...; omit for token refresh requests. |
Send DPoP on every request
Authorization: Bearer <access_token>
DPoP: <signed_dpop_jwt> A new DPoP JWT must be generated for every request because htm, htu, and jti change.
Organization access tokens
Organization access tokens (prefixed hsoat_) are long-lived bearer credentials for server-side automation that runs without anyone signing in — CI jobs, internal integrations, scheduled scripts. Unlike a PAT or an OAuth app, the token is the credential: there is no token exchange, no refresh, and no browser flow. (They don't use OIDC scopes, refresh, or DPoP — the sections above apply to PATs and OAuth apps.)
An owner, manager, or Manage-IT member creates one in the Hubstaff web app under Settings → Organization → API tokens and assigns it to an existing organization member. The token then authenticates as that member, with exactly that member's current organization role and access — there are no separate token scopes to configure.
Call the API
Send the token directly in the Authorization header on every request. There is nothing to exchange or refresh, and these tokens are not DPoP-bound — use the secret as-is until the token expires, is revoked, or is reassigned.
Authorization: Bearer hsoat_...Lifecycle & reassignment
- You choose an expiration when creating the token — Never (the default; the token never expires) or 30, 60, or 90 days. If it does expire, the token stops working and the API responds
401; create a new token to replace it. - The token keeps working as long as its assignee is an active member of the organization.
- If the assignee is removed from the organization or their account is deleted, the token stops working and the API responds
401. Hubstaff notifies the organization owners so they can reassign it. - An admin can reassign the token to another member at any time — the secret stays the same, so running automation keeps working; only the member it acts as changes. (Only an owner may assign a token to an owner.)
- Revoking a token in Settings makes it stop working immediately.