Skip to content

HubSpot Source

What it ingests

CRM records and the engagement/activity stream from a HubSpot portal:

HubSpot objectGraph entitySignal
CompanyOrganizationcompany
ContactPerson (identity)contact
DealProjectdeal
TicketIssueticket
NoteCommentnote
EmailCommentemail
CallCommentcall
MeetingMeetingmeeting
TaskActionItemtask

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 KeyPrivate app
StatusHubSpot's stated directionworks; the UI now labels these "Legacy apps"
Maturitypublic beta (since 2026‑02‑10)long-stable
Webhooksnot supportedsupported (UI-configured only)
Ownershipaccount-leveltied 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.

bash
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

KeyRequiredNotes
enabled_objectsnoComma-separated object kinds. Blank polls every supported kind. Unknown names are rejected at configure time.
portal_idnoYour 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_typenoOverride 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

  1. In HubSpot, go to Settings → Integrations → Private Apps (or create a Service Key if your account has the beta).
  2. Grant the scopes below.
  3. Copy the access token — that's the credential secret.
  4. Note your portal id (the number in your HubSpot URL) for portal_id.

Scopes

ObjectsScope
Contacts / Companies / Dealscrm.objects.{contacts,companies,deals}.read
Ticketsticketsnot crm.objects.tickets.read
Engagementscrm.objects.contacts.read (plus per-kind scopes where your portal offers them)
Ownerscrm.objects.owners.read
Email bodiessales-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

json
{
  "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 its updatedAfter is unreliable. The source uses POST /crm/objects/2026-03/{type}/search sorted ascending on the modification timestamp.
  • Contacts use a different timestamp property. Companies, deals and tickets sort on hs_lastmodifieddate; contacts sort on lastmodifieddate, because a contact can carry a null hs_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_transcript does not exist and hs_call_recording_url was sunset in 2024. Call bodies (hs_call_body) — the rep's own notes — are what gets ingested.
  • Conversations inbox. A separate signal; deferred.