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

lib/plugins/hubspot/ — HubSpotClient base, HubSpotListsClient, pyb.ts (@payability/pyb-hubspot)

CRM queue publishers

lib/plugins/sqs-client.ts

Webhook signature

lib/utils/hubspot/webhook-signature.ts

Webhook normalisation

lib/utils/hubspot/webhook-normalize.ts

Inbound endpoint

app/api/webhooks/hubspot/route.ts

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

Seller

Company

Seller.hubspotCompanyId

Contact

Contact

Contact.hubspotContactId

Call

Engagement

Call.hubspotEngagementId

FollowUpTask

Task

FollowUpTask.hubspotTaskId

Activity

Engagement

Activity.hubspotEngagementId

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

Crm.DialerCallEnded

Retell call_analyzed handler

An eligible AI call finished

Crm.DealUpdate

Nooks call.logged handler

Move a deal stage, e.g. closedlost

Crm.NewActivity

emailSend, Resend handlers

An outbound touch was made

Crm.CustomEvent

Resend handlers

Custom timeline event

Crm.TaskCreate

Sequence crm_task step

Create a task

Contact.Update

Sequence crm_event step

Update contact properties

Contact.Unsubscribe

/api/unsubscribe

Record an opt-out

Crm.HubSpotWebhook

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_task step skips with no_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.

Ordering

messageGroupId is the workspace id with dashes stripped, or the HubSpot contact id where per-contact ordering matters (sequence crm_event steps). Two updates to the same contact cannot be applied out of order.

Call hand-off eligibility

A call is handed to the CRM when all of the following hold:

  • durationSec is set and >= 10

  • inVoicemail is false

  • callScreeningDetected is false

  • disposition is in HUBSPOT_WHITE_LISTED_DISPOSITIONS: Qualified_Lead, Amazon_Seller, Callback_Requested, Wrong_Number, Do_Not_Call, Not_a_Seller, AI_Issue

  • CampaignTarget.isTestTarget is 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 hubSync.syncCall(…​) call remains in lib/events/handlers/call-ended.ts, commented out. The live path publishes Crm.DialerCallEnded to SQS instead. Call.hubspotSyncStatus is therefore set by the downstream consumer, not by this handler.

Lifecycle stages that gate outreach

Set Values and effect

SKIP_CALL_LIFECYCLE_STAGES

customer, marketingqualifiedlead — pre-validation skips the target with lifecycle_stage

HUBSPOT_WARM_LEAD_LIFECYCLE_STAGES

salesqualifiedlead, opportunity, closedwon, closedlost — defined for the warm_lead skip reason, currently unreferenced by the dial path

Inbound webhooks

POST /api/webhooks/hubspot:

  1. Read the raw body — the signature is computed over the exact bytes.

  2. Validate the HubSpot HMAC or legacy signature with HUBSPOT_WEBHOOK_CLIENT_SECRET. Unlike the Resend route, this one rejects when the secret is unset.

  3. Normalise the payload.

  4. 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 email step logs an email engagement (hubActivity.logEmailActivity);

  • the send_email MCP tool logs an engagement when hubspot_contact_id is 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.