Getting started
Webhooks
Webhooks deliver real-time notifications when events happen in your Hubstaff organization — timer changes, project/task updates, schedule events, billing snapshots, and more. Three endpoints (create → activate → delete) plus a verification handshake your endpoint implements.
- OAuth scope:
hubstaff:writeto create / activate / delete,hubstaff:readto inspect. - Scope your webhook to the right owner: org-wide (
/organizations/{org_id}/webhooks), single project (/projects/{project_id}/webhooks), or the current user (/users/me/webhooks).
Step 1 — Create the webhook
Returns a webhook id and starts the verification handshake (Step 2) immediately. The webhook is inactive until you complete Step 3.
curl -X POST https://api.hubstaff.com/v2/organizations/<org_id>/webhooks \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"events": ["timer.start", "timer.stop"],
"target_url": "https://your-tunnel.ngrok.app/webhook"
}' Use ["*"] to subscribe to every event (see the Events table below for the full list).
Step 2 — Verify your endpoint
Right after Step 1, Hubstaff sends an empty-body POST to your target_url with one header:
POST /webhook HTTP/1.1
Host: your-tunnel.ngrok.app
Content-Length: 0
X-Hook-Secret: 8f3c1a… ← unique per webhook, store thisYour endpoint must respond 200 OK and echo the same header back. The verification ping is async — Hubstaff hits your endpoint within ~15 seconds after Step 1, so make sure the endpoint is live first.
HTTP/1.1 200 OK
X-Hook-Secret: 8f3c1a…
Content-Length: 0id. It is never returned by any other API call. Step 3 — Activate
Send the secret you saved in Step 2 back to Hubstaff as the X-Hook-Secret header. After this returns 200, events start flowing to target_url.
curl -X POST https://api.hubstaff.com/v2/webhooks/<webhook_id>/activate \
-H "Authorization: Bearer <access_token>" \
-H "X-Hook-Secret: 8f3c1a…"Step 4 — Receive events
Every delivery has this shape:
POST /webhook HTTP/1.1
Content-Type: application/json
X-Hook-Signature: 1d4e2c… ← HMAC-SHA256(body, secret)
X-Product-Id: hubstaff
{
"id": "69d37f9f-483e-4be1-92c3-b2377203a8cd",
"event": "timer.start",
"created_at": "2024-06-20T14:10:55.196912",
"payload": {
"organization_id": 13,
"project_id": 841201,
"project_name": "DevSecOps",
"task_id": null,
"user_id": 651956,
"user_name": "Alex Yarotsky",
"tracking_started_at": "2024-06-20T14:10:46.235Z"
}
}Step 5 — Verify the signature
Always verify before processing. Compute HMAC-SHA256 of the raw request body using the secret you saved in Step 2 as the key, compare it to X-Hook-Signature in constant time. Anything that doesn't match — drop with 401.
# Sanity-check a real delivery with openssl:
echo -n '<raw request body bytes>' \
| openssl dgst -sha256 -hmac '<secret>' -hex
# Should print the same hex string that came in X-Hook-Signature.Statuses & failure handling
GET /v2/webhooks/<id> returns a status field. What it means:
| Status | Meaning |
|---|---|
| inactive | Just created. Verification ping hasn't been sent yet (~15s queue delay). |
| active | Verification succeeded — endpoint replied 2xx and echoed X-Hook-Secret. Events are flowing. |
| pending | Endpoint replied 2xx but did not echo the header. Call POST /webhooks/<id>/activate with the secret to flip to active. |
| disabled | Verification ping failed (non-2xx, timeout, or unreachable). Or an event delivery returned 404 / 410. Disabled webhooks can't be revived — delete and recreate. |
For event delivery to an active webhook: 2xx is success, any 5xx / timeout / connection error is retried in the background. Only persistent 404 or 410 on the target_url disable the webhook (you removed the endpoint).
Inspect & delete
# Fetch a webhook (status, events, target_url) — needs hubstaff:read
curl https://api.hubstaff.com/v2/webhooks/<webhook_id> \
-H "Authorization: Bearer <access_token>"
# Permanently delete — stops delivery immediately
curl -X DELETE https://api.hubstaff.com/v2/webhooks/<webhook_id> \
-H "Authorization: Bearer <access_token>"Events
| Event | Description |
|---|---|
| * | All events |
| timer.start | Time tracking started |
| timer.stop | Time tracking stopped |
| project.create | Project created |
| client.create | Client created |
| task.create | Task created |
| task.assign | Task assigned |
| task.unassign | Task unassigned |
| schedule.create | Schedule created |
| schedule.update | Schedule updated |
| schedule.delete | Schedule deleted |
| shift.ontime | Shift started on time |
| shift.early | Shift started early |
| shift.late | Shift started late |
| shift.abandoned | Shift abandoned |
| shift.missed | Shift missed |
| work_break.create | Break started |
| activity.create | Activity created (on request) |
| activity.update | Activity updated (on request) |
| activity.delete | Activity deleted (on request) |
| idle_time.start | Idle time started (beta) |
| billing.occupied_seats | Occupied seats snapshot |
For billing.occupied_seats, occupied_seats is a snapshot of the latest value up to the previous UTC day at delivery time — not recomputed live, and not the same as the purchased/paid seat quota.