# HubSpot integration

> Create TimeTracking clients and placements from HubSpot companies and won deals with external-ID upserts, and write approved-hour totals back using GET /days.

URL: https://timetracking.tech/developers/integrations/ats-crm/hubspot
Last updated: 2026-10-24

## 1. Summary

**Status:** Recipe (no native connector).

**Direction:** HubSpot to TimeTracking for clients and placements; TimeTracking to HubSpot for approved-hour totals (optional).

Typical use: an agency sells with HubSpot. When a deal is won, the client and placement must exist in TimeTracking. Approved hours can be written back to the deal for account management.

## 2. Mechanism

- **HubSpot side.** A workflow ("Deal stage is Closed won") with *Send a webhook* (Operations Hub Professional), or an iPaaS trigger ([Zapier](https://timetracking.tech/developers/integrations/ipaas/zapier), [Make](https://timetracking.tech/developers/integrations/ipaas/make), [n8n](https://timetracking.tech/developers/integrations/ipaas/n8n)). Objects via CRM v3 (`/crm/v3/objects/companies`, `/deals`, `/contacts`, associations).
- **TimeTracking side.** `PUT /clients/by-external/hubspot/{companyId}`, `PUT /people/by-external/hubspot/{contactId}`, `PUT /placements/by-external/hubspot/{dealId}`. HubSpot ids are the keys, so nothing has to be written back to HubSpot to keep the link.
- **Back-sync.** `GET /days?from&to&placement_id=…&status=approved,exported` and `PATCH /crm/v3/objects/deals/{id}`.

Assumption — verify with your HubSpot admin: many agencies model placements as a **custom object** ("Placement") or as deals with custom properties. Adapt the mapping.

## 3. Prerequisites

- HubSpot private app token with `crm.objects.companies.read`, `crm.objects.deals.read/write`, `crm.objects.contacts.read` (plus custom object scopes if used).
- TimeTracking API key (`write`). A read-only key is enough for the back-sync (`GET /days`, `GET /placements/{id}`).
- Custom HubSpot property for totals: `tt_approved_hours_this_month` (number).

## 4. Entity mapping

| HubSpot object.property | TimeTracking field | Notes |
|---|---|---|
| Company `hs_object_id` | client external ref | |
| Company `name` | `name` | |
| Company `domain` | `mailDomains` | Matches inbound timesheet emails from that domain. |
| Contact (worker) `hs_object_id` | person external ref | Only if workers live in HubSpot; often they come from an ATS. |
| Contact `firstname`, `lastname`, `email`, `phone`, `employee_number` | person fields | |
| Deal / custom "Placement" `hs_object_id` | placement external ref; `ref` `HS-<id>` | |
| Deal `start_date` / `end_date` (custom) | `startsOn` / `endsOn` | |
| Deal `job_title` / `dealname` | `roleTitle` | |
| Sum of `GET /days` minutes for the deal's placement | `tt_approved_hours_this_month` | Hours = minutes / 60. |

**External-ID strategy.** `source` = `hubspot`. Upserts are idempotent and full replaces. To find the TimeTracking placement for a deal, call `PUT` again (idempotent, returns the record) or page `GET /placements` and match `external_ref`.

## 5. Step-by-step setup

1. Create the HubSpot workflow: trigger *Deal stage = Closed won*; *Send a webhook* (POST) with deal id, associated company id and contact id.
2. In the receiver/scenario:

```sh
curl -s -X PUT "$TT/clients/by-external/hubspot/1873402" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"name":"VDL Montage","externalRef":"1873402","approvalMode":"both","mailDomains":"vdl-montage.example"}'
curl -s -X PUT "$TT/people/by-external/hubspot/551" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"employeeNumber":"1001","firstName":"Jan","lastName":"de Vries","email":"jan@example.com"}'
curl -s -X PUT "$TT/placements/by-external/hubspot/9921833" -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"personExternalRef":"551","clientExternalRef":"1873402","ref":"HS-9921833","roleTitle":"Monteur","startsOn":"2026-11-02"}'
```

3. Optional back-sync (scheduled, or on `timesheet.approved`):

```sh
curl -s "$TT/days?from=2026-12-01&to=2026-12-31&placement_id=<placement-uuid>&status=approved,exported&limit=500" \
  -H "Authorization: Bearer $KEY"
# sum items[].minutes / 60, then:
curl -s -X PATCH "https://api.hubapi.com/crm/v3/objects/deals/9921833" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" -H "content-type: application/json" \
  -d '{"properties":{"tt_approved_hours_this_month":"15.5"}}'
```

## 6. Sync schedule and triggering events

| Trigger | Action |
|---|---|
| HubSpot workflow: deal Closed won | Upsert client, (person), placement. |
| HubSpot workflow: `end_date` changed | Upsert the placement again (full replace). |
| Deal closed lost after start | `DELETE /placements/{id}` (deactivates). |
| `timesheet.approved` webhook or nightly | Recompute totals from `GET /days`. |
| `conflict.created` (optional) | Create a HubSpot task for the account manager. |

## 7. Corrections, conflicts and locked periods

- HubSpot never edits hours. Totals are recomputed from `GET /days` each time (never incremented).
- A correction returns a day to `submitted`; filtering on `status=approved,exported` excludes it until re-approved.
- Exported/locked days (`423`) are final; corrections appear in later periods.

## 8. Testing checklist

- [ ] Closing a test deal returns `created: true`; re-triggering returns `created: false`.
- [ ] Company domain lands in `mail_domains`.
- [ ] Deal totals equal the sum of `GET /days` minutes / 60.

## 9. Troubleshooting

| Error | Fix |
|---|---|
| `422` "Person and client must exist …" | Upsert company and contact first (same source `hubspot`). |
| `422` on placements | Dates not `YYYY-MM-DD`. |
| `403` "Admin role required" | Webhooks need an admin user or an `admin`-scope key. |
| `429` | Wait `Retry-After` (60 s). |
| HubSpot `401`/`403` | Missing private app scope. |

## 10. Limits and known gaps

- No client-contact API; approvers are set in the dashboard.
- No filter on `external_ref` in list endpoints; repeat the idempotent `PUT` to resolve a record.
- HubSpot marketplace app with OAuth2: Planned.

## 11. Build it with Claude

```text
Build a HubSpot ↔ TimeTracking integration as an n8n workflow (export JSON) or a TypeScript service.
Inbound: HubSpot workflow webhook on deal Closed won (dealId, companyId, contactId) → read via HubSpot CRM v3 →
TimeTracking PUT /clients/by-external/hubspot/{companyId} {name, mailDomains},
PUT /people/by-external/hubspot/{contactId} {firstName, lastName, email, employeeNumber},
PUT /placements/by-external/hubspot/{dealId} {personExternalRef, clientExternalRef, ref "HS-<dealId>",
roleTitle, startsOn, endsOn}. Upserts are full replaces and idempotent.
Outbound (nightly): GET /days?from&to&placement_id&status=approved,exported (follow next_cursor), sum minutes,
PATCH deal property tt_approved_hours_this_month. Base https://app.timetracking.tech/api/v1, Bearer key.
Ask me for my HubSpot property names first. No deletes.
```
