HubSpot Source
What it ingests
CRM records and the engagement/activity stream from a HubSpot portal:
| HubSpot object | Graph entity | Signal |
|---|---|---|
| Company | Organization | company |
| Contact | Person (identity) | contact |
| Deal | Project | deal |
| Ticket | Issue | ticket |
| Note | Comment | note |
Comment | email | |
| Call | Comment | call |
| Meeting | Meeting | meeting |
| Task | ActionItem | task |
The engagements are the point. Plenty of systems know which deals are open; what HubSpot adds is the record of what was actually said to the customer — note bodies, email bodies, call notes, meeting outcomes. Each engagement is resolved to the deal, company, or contact it concerns, so a question like "what have we told this account about renewal" has an answer in the graph.
Read-only and poll-based. Each object kind has its own signal, so you can disable a noisy stream (emails, typically) from the source's bindings without disabling the source.
Credential
References a hubspot_access_token credential by id.
Either HubSpot mechanism works, and Corveil cannot tell them apart — a private app token and an account-level Service Key both authenticate as Authorization: Bearer pat-…. Pick based on your account, not on Corveil:
| Service Key | Private app | |
|---|---|---|
| Status | HubSpot's stated direction | works; the UI now labels these "Legacy apps" |
| Maturity | public beta (since 2026‑02‑10) | long-stable |
| Webhooks | not supported | supported (UI-configured only) |
| Ownership | account-level | tied to the user who created it — deletes with that employee |
That last row is the one that bites. A private app created by someone who later leaves the company starts failing with USER_DOES_NOT_HAVE_PERMISSIONS. If you have the Service Key beta, prefer it; otherwise create the private app under an account that will outlive any individual.
Neither token expires, so there is nothing to refresh and no callback endpoint to expose. OAuth is deliberately not supported — it exists for multi-portal marketplace distribution and buys a single-portal integration nothing.
curl -sS -X POST https://your-corveil-host/api/credentials \
-H "Authorization: Bearer <admin>" -H "Content-Type: application/json" \
-d '{"type":"hubspot_access_token","name":"acme-hubspot","secret":"pat-na1-..."}'Config fields
| Key | Required | Notes |
|---|---|---|
enabled_objects | no | Comma-separated object kinds. Blank polls every supported kind. Unknown names are rejected at configure time. |
portal_id | no | Your numeric HubSpot account id, used only to build links back to each record. Blank omits links. The source logs the real value at connect time, so you can fill it in afterwards. |
default_entity_type | no | Override the graph entity type produced from each record. Blank uses the per-object fallback (Organization for companies, Project for deals, …). Validated against the org taxonomy on save. |
Supported object names: companies, contacts, deals, tickets, notes, emails, calls, meetings, tasks.
Provider-side setup
- In HubSpot, go to Settings → Integrations → Private Apps (or create a Service Key if your account has the beta).
- Grant the scopes below.
- Copy the access token — that's the credential secret.
- Note your portal id (the number in your HubSpot URL) for
portal_id.
Scopes
| Objects | Scope |
|---|---|
| Contacts / Companies / Deals | crm.objects.{contacts,companies,deals}.read |
| Tickets | tickets — not crm.objects.tickets.read |
| Engagements | crm.objects.contacts.read (plus per-kind scopes where your portal offers them) |
| Owners | crm.objects.owners.read |
| Email bodies | sales-email-read — see below |
Whether crm.objects.{notes,emails,calls,meetings,tasks}.read are grantable varies by portal and by when the app was created; check what your scope picker actually offers. Grant what is listed there.
⚠️ sales-email-read — the scope that fails silently
Without it, HubSpot returns email engagements at HTTP 200 with the body replaced by The content of this email has been redacted. and empty to/cc/bcc fields. Nothing errors. Counts look right.
Corveil's Test connection therefore checks the content, not the status code: it fetches one email and fails the connection test if the body is the redaction placeholder, naming the missing scope. If you see that failure, add sales-email-read and reconnect — the source is telling you it would otherwise ingest thousands of empty emails while reporting itself healthy.
If the scope is revoked later, the source drops the placeholder rather than storing it as if a rep had written it, flags the record content_redacted, and warns hourly.
Wire-up
POST /api/sensors
{
"sensor_type": "hubspot",
"name": "Acme HubSpot",
"config": {
"enabled_objects": "companies, contacts, deals, notes, emails, calls, meetings",
"portal_id": "12345678"
},
"credentials": { "access_token": "<hubspot_access_token credential id>" }
}Verify
Run Test connection. It authenticates the token, reports the portal id, and — when emails are enabled — asserts that email bodies come back unredacted.
Once polling, each object kind's count appears in the per-poll summary log (companies_collected, emails_collected, …). A kind stuck at zero while the others move is the signal to check that kind's scope.
How incremental sync works
Worth knowing, because it explains a few behaviours you may notice.
- The Search API, not the list endpoint.
GET /crm/v3/objects/{type}has no sort parameter, pages by id rather than modification time, and itsupdatedAfteris unreliable. The source usesPOST /crm/objects/2026-03/{type}/searchsorted ascending on the modification timestamp. - Contacts use a different timestamp property. Companies, deals and tickets sort on
hs_lastmodifieddate; contacts sort onlastmodifieddate, because a contact can carry a nullhs_lastmodifieddate. Getting this wrong returns an empty result set at HTTP 200 — the most common cause of a dead HubSpot sync. Corveil pins it with a test. - Each poll re-reads a five-minute overlap. HubSpot's search index is eventually consistent, so a strict resume would step permanently past any record the index was briefly lagging on. Re-read events are deduplicated.
- Deletes are invisible. Archived records never appear in search, so a record vanishing from results is not treated as a deletion.
- 10,000 results per query is a hard wall. The source windows around it by re-anchoring on the last record's timestamp, and falls back to an id-ordered walk when a bulk import puts more than 10,000 records in a single millisecond.
- Rate limits are shared. HubSpot's daily budget is shared with every integration installed in the portal. The source paces itself under the 5 req/sec search limit and backs off for the cycle on daily exhaustion rather than retrying — daily limits reset at midnight in the portal's timezone.
Not included
- OAuth / marketplace distribution. Only needed for multi-portal apps.
- Webhooks. Private apps do support them (since 2023), but subscriptions can only be created in the app's own UI, never via API — so they cannot be provisioned at install time. Polling is the supported path; webhooks would be a latency optimization set up by hand.
- Call transcripts.
hs_call_transcriptdoes not exist andhs_call_recording_urlwas sunset in 2024. Call bodies (hs_call_body) — the rep's own notes — are what gets ingested. - Conversations inbox. A separate signal; deferred.