# HubSpot

Source module: `app/connectors/hubspot.py`. Kind: **both** (declared; claims are not produced yet — see limitations). Display name: "HubSpot".

## Purpose

Reads ticket pipeline-stage history, meetings and deals as **facts** so that resolution, meeting-booked and lead-qualified outcomes can be checked against HubSpot. Intended to also ingest Breeze Customer Agent resolutions as claims when an export is available.

## Auth and scopes

| Item | Value |
|---|---|
| Method | Access token: a service key (Settings → Integrations → Service keys, the replacement for private apps on newer accounts) or a private app token, with read scopes only, sent as `Authorization: Bearer`, against `https://api.hubapi.com` |
| Credentials stored (encrypted) | `access_token`; optional `base_url` |
| Least-privilege scopes | **`tickets`** (read), **`crm.objects.deals.read`**, **`crm.objects.meetings.read`**, **`crm.objects.contacts.read`** (declared for email hashing; see limitations). No write scopes. |
| Connection test | `GET /crm/v3/objects/tickets?limit=1` |
| Non-secret config | none |

## Fields pulled and why

| Object / endpoint | Properties | Used for |
|---|---|---|
| `POST /crm/v3/objects/tickets/search` (filter `hs_lastmodifieddate GT since`) | `hs_pipeline_stage`, `createdate`, `hs_lastmodifieddate`, `closed_date`, `hs_ticket_priority`, `source_type` | Subject id, watermark, channel |
| `GET /crm/v3/objects/tickets/{id}?propertiesWithHistory=hs_pipeline_stage` | stage history (`value`, `timestamp`) | Status-change and reopen facts |
| `POST /crm/v3/objects/meetings/search` | `hs_meeting_outcome`, `hs_meeting_start_time`, `hs_timestamp` | Meeting facts |
| `POST /crm/v3/objects/deals/search` | `dealstage`, `closedate`, `hs_lastmodifieddate` | Opportunity-stage facts |

Ticket subject/content, notes, emails, calls, contact names and any other properties are not requested.

## What is hashed / never stored

- No raw identifiers are stored. The connector declares the contacts read scope for hashing contact emails, but the current implementation does not fetch contact records; facts from HubSpot therefore carry no `customer_hid` and link to other sources only by exact subject id.
- The access token is stored only as ciphertext.

## Incremental sync and watermark

- All three searches filter on `hs_lastmodifieddate > <watermark in epoch ms>` (0 on first sync), page size 100, cursor via `paging.next.after`.
- One additional request per ticket fetches the pipeline-stage history.
- Watermark = maximum ticket `hs_lastmodifieddate` (meetings and deals do not advance it).
- Upserts are idempotent.

## Rate-limit handling

Shared `HttpClient` (Retry-After, exponential backoff on `429`/`5xx`, 6 attempts, immediate `reauth` failure on `401`/`403`). HubSpot private apps have per-10-second and daily request limits; the per-ticket history request dominates.

## Fact types produced

| `fact_type` | Subject / kind | When | Attributes | `raw_ref` |
|---|---|---|---|---|
| `status_change` | `hubspot_ticket_{id}` / `ticket` | Each pipeline-stage history entry, in timestamp order | `from` (previous stage or null), `to` (stage id or name, lower-cased), `actor: "agent"`, `channel: <source_type>` | `hubspot:ticket:{id}:stage:{i}` |
| `reopen` | `hubspot_ticket_{id}` / `ticket` | Stage moves from `closed` or `4` (default closed stage id) to another stage | — | `hubspot:ticket:{id}:reopen:{i}` |
| `meeting_event` | `hubspot_meeting_{id}` / `customer` | Each meeting | `status: occurred` when `hs_meeting_outcome == completed`, otherwise the outcome value, or `scheduled` | `hubspot:meeting:{id}` |
| `opportunity_stage` | `hubspot_deal_{id}` / `opportunity` | Each deal (current stage) | `stage: <dealstage lower-cased>` | `hubspot:deal:{id}:{dealstage}` |

## Claims produced

None yet. Breeze Customer Agent resolutions must currently be uploaded via CSV mapped to `hubspot_breeze_customer_agent_resolution`, with `subject_id` values of the form `hubspot_ticket_{id}` so they match the facts above.

## Subject id format

`hubspot_ticket_{ticketId}`, `hubspot_meeting_{meetingId}`, `hubspot_deal_{dealId}`.

## Known limitations

- **No claims.** The connector is declared `kind = both`, but Breeze resolution export ingestion is not implemented.
- **No `customer_hid`.** Contact emails are not fetched, so HubSpot facts cannot be linked to other sources by hashed email; they link by exact subject id only.
- Pipeline stage values are HubSpot stage ids unless the portal uses labels; contracts that test `record_status_in: [closed, …]` need the closed stage to be literally `closed` or `4`. Map stages through a CSV upload if the portal uses custom ids.
- Meeting facts are keyed by meeting id, not by the associated contact or ticket; associations are not fetched, so a meeting cannot be linked to a lead claim without an explicit subject link.
- Deal stage facts record the current stage at sync time, not the full stage history; the `occurred_at` is the deal's `hs_lastmodifieddate`.
- No `customer_message`, `human_reply`, `escalation` or `csat_response` facts.
