HubSpot
HubSpot is the CRM of record for the sales team. The application mirrors identity into it and hands off qualified outcomes, but does not treat it as the source of truth for contactability — that lives in the registries.
| Concern | File |
|---|---|
Client wrapper |
|
CRM queue publishers |
|
Webhook signature |
|
Webhook normalisation |
|
Inbound endpoint |
|
Configuration
HUBSPOT_PRIVATE_APP_TOKEN=
HUBSPOT_WEBHOOK_CLIENT_SECRET= # required — the inbound route rejects when unset
HUBSPOT_CLIENT_ID=
HUBSPOT_CLIENT_SECRET=
HUBSPOT_REFRESH_TOKEN=
HUBSPOT_OWNER_ID= # read into HUBSPOT_AI_OWNER_ID, default '86817215'
CRM_QUEUE_URL=
Constants in lib/constants.ts: HUBSPOT_PORTAL_ID ("9298797"), HUBSPOT_AI_OWNER_ID,
HUBSPOT_TASK_OWNER_ID ('550592045'), HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID ("44467216"),
HUBSPOT_LIFECYCLE_STAGE_PROPERTY ('lifecyclestage').
Object mapping
| Local | HubSpot | Link field |
|---|---|---|
|
Company |
|
|
Contact |
|
|
Engagement |
|
|
Task |
|
|
Engagement |
|
Contacts also mirror HubSpot’s own vocabulary: ContactLifecycleStage mirrors lifecyclestage and
ContactLeadStatus mirrors hs_lead_status, so no translation layer is needed.
Outbound: the CRM queue
Writes go to SQS, not to the HubSpot API. A downstream Payability service applies them.
event_type |
Producer | Meaning |
|---|---|---|
|
Retell |
An eligible AI call finished |
|
Nooks |
Move a deal stage, e.g. |
|
|
An outbound touch was made |
|
Resend handlers |
Custom timeline event |
|
Sequence |
Create a task |
|
Sequence |
Update contact properties |
|
|
Record an opt-out |
|
Inbound webhook route |
A normalised HubSpot notification |
Queueing rather than calling directly means a HubSpot outage or rate limit never blocks a call from completing or an email from sending, and one service owns all CRM reconciliation.
The conditional-publish rule
A CRM message is published only when a HubSpot contact id is available:
-
Resend handlers check for the id before publishing.
-
The sequence
crm_taskstep skips withno_hubspot_contact. -
The Nooks closed-lost flow logs a warning and sends nothing.
The consumer cannot resolve an unidentified contact, so an id-less message is guaranteed to fail downstream. Failing early and visibly beats an undeliverable queue.
Call hand-off eligibility
A call is handed to the CRM when all of the following hold:
-
durationSecis set and>= 10 -
inVoicemailis false -
callScreeningDetectedis false -
dispositionis inHUBSPOT_WHITE_LISTED_DISPOSITIONS:Qualified_Lead,Amazon_Seller,Callback_Requested,Wrong_Number,Do_Not_Call,Not_a_Seller,AI_Issue -
CampaignTarget.isTestTargetis not true
Negative outcomes such as Do_Not_Call and Wrong_Number are deliberately whitelisted — the CRM needs
them in order to suppress future outreach.
HUBSPOT_BLOCKED_DISPOSITIONS records the inverse set for reference: AI_Screening,
Voicemail_Detected, dial_busy, dial_no_answer, invalid_destination, Gatekeeper, IVR.
|
The direct |
Lifecycle stages that gate outreach
| Set | Values and effect |
|---|---|
|
|
|
|
Inbound webhooks
POST /api/webhooks/hubspot:
-
Read the raw body — the signature is computed over the exact bytes.
-
Validate the HubSpot HMAC or legacy signature with
HUBSPOT_WEBHOOK_CLIENT_SECRET. Unlike the Resend route, this one rejects when the secret is unset. -
Normalise the payload.
-
Publish as
Crm.HubSpotWebhook.
The application does not act on HubSpot notifications itself; forwarding them keeps CRM reconciliation in one place.
Direct API calls
Two paths still call HubSpot inline, both best-effort:
-
the sequence
emailstep logs an email engagement (hubActivity.logEmailActivity); -
the
send_emailMCP tool logs an engagement whenhubspot_contact_idis supplied.
Both are wrapped so a failure is warned and the primary action still succeeds.
A third path is fail-closed: reengagementDailyListCron pre-list uses
HubSpotListsClient (lib/plugins/hubspot/) to find-or-create today’s MANUAL
contact segments via GET/POST /crm/lists/2026-03 using
HUBSPOT_PRIVATE_APP_TOKEN. Names are
Outreach-Re-Engagement-{YYYY-MM-DD}-{First}-{Last}. Existence must be known
before pick fan-out, so this does not go through SQS. The private app needs
crm.lists.read and crm.lists.write (or the crm.segments.* equivalents).
A missing token, HubSpot error, or two active reps that slug to the same name
fails the step; picks are not enqueued. Segment ids are not stored on SalesRep.
Each pick event carries { salesRepId, listId }. After insert, assigned daily-list
rows get hubspotListId and those contacts are added with
PUT /crm/lists/2026-03/{listId}/memberships/add (batches of 100). Unassigned
contacts are skipped.
A separate 07:30 America/New_York run, reengagementNeverCalledDailyList,
creates one shared MANUAL list named Outreach-Never-Called-{YYYY-MM-DD}.
It adds up to 1,000 contacts who already appear on a re-engagement daily list
and have no human-rep call in hubspot4.v_calls_latest (a call counts when
ownerid is present and is not the AI owner). Memberships use the same
batches of 100. This list is not split by sales rep and does not write
reengagement_daily_list_entries.
Unsubscribe compliance
Contact.Unsubscribe carries legal_basis = LEGITIMATE_INTEREST_OTHER
(CRM_UNSUBSCRIBE_LEGAL_BASIS) and a human-readable
CRM_UNSUBSCRIBE_LEGAL_BASIS_EXPLANATION, so HubSpot’s subscription record has an auditable reason.
HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID identifies the subscription being changed.