CRM queue and HubSpot events

CRM writes are not made inline. The application publishes messages to an SQS queue and a downstream Payability service applies them to HubSpot. Inbound HubSpot notifications travel the same path in reverse.

Publishers

lib/plugins/sqs-client.ts:

Function Purpose

publishCrmWorkflowEvent(body, opts?)

Generic publish to CRM_QUEUE_URL; opts.messageGroupId sets FIFO ordering

publishCrmNewActivity(…​)

Crm.NewActivity — an outbound touch was made

publishCrmCustomEvent(…​)

Crm.CustomEvent — a custom timeline event

publishCrmCreateTask(…​)

Crm.TaskCreate — create a HubSpot task

publishIncomingEmailCategorise(…​)

Email.IncomingCategorise to INCOMING_EMAIL_SQS_QUEUE_URL

Event types

event_type Meaning Producer

Crm.DialerCallEnded

An eligible AI call finished

Retell call_analyzed handler

Crm.DealUpdate

Move a deal stage, e.g. closedlost

Nooks call.logged handler

Crm.NewActivity

An outbound touch was made

emailSend, Resend handlers

Crm.CustomEvent

Custom timeline event

Resend handlers

Crm.TaskCreate

Create a HubSpot task

Sequence crm_task step

Crm.HubSpotWebhook

A normalised inbound HubSpot notification

POST /api/webhooks/hubspot

Contact.Update

Update contact properties

Sequence crm_event step

Contact.Unsubscribe

Record an opt-out

/api/unsubscribe

Email.IncomingCategorise

Classify an inbound reply

Resend email.received handler

CrmWorkflowEventTypeEnum in lib/enums.ts covers Crm.DialerCallEnded and Crm.DealUpdate; CRM_EVENT_TYPES in lib/sequences/crm-event.ts covers the types a sequence step may select (Crm.TaskCreate, Contact.Update).

The conditional-publish rule

Handlers publish CRM messages only when a HubSpot contact id is available. This is consistent across producers:

  • Resend handlers call publishCrmCustomEvent / publishCrmNewActivity only when the contact has a HubSpot id.

  • The sequence crm_task step skips with no_hubspot_contact when the contact is not in HubSpot.

  • The Nooks closed-lost flow logs a warning and sends nothing when it cannot resolve an id.

The reason is that the consumer has no way to resolve an unidentified contact, so an id-less message is guaranteed to fail downstream. Failing early and visibly is better than filling the queue with undeliverable work.

FIFO ordering

messageGroupId is set to the workspace id with dashes stripped (Crm.DialerCallEnded), or to the HubSpot contact id falling back to the workspace id (sequence crm_event steps). Messages within a group are processed in order, so two updates to the same contact cannot be applied out of sequence.

Inbound HubSpot webhooks

POST /api/webhooks/hubspot:

  1. Read the raw body.

  2. Validate the HubSpot HMAC or legacy signature using HUBSPOT_WEBHOOK_CLIENT_SECRET (lib/utils/hubspot/webhook-signature.ts). Unlike the Resend route, this one rejects when the secret is unset.

  3. Normalise the payload (lib/utils/hubspot/webhook-normalize.ts).

  4. Publish as Crm.HubSpotWebhook through publishCrmWorkflowEvent.

The application does not act on HubSpot notifications directly; it forwards them so a single downstream service owns all CRM reconciliation.

HubSpot client

lib/plugins/hubspot/pyb.ts wraps @payability/pyb-hubspot and @hubspot/api-client for the best-effort engagement logs (sequence email step and the send_email MCP tool). HubSpotListsClient in the same folder extends HubSpotClient for the daily-list pre-list find-or-create. Existing imports from lib/plugins/pyb-hubspot still work.

Related constants in lib/constants.ts: HUBSPOT_PORTAL_ID, HUBSPOT_AI_OWNER_ID, HUBSPOT_TASK_OWNER_ID, HUBSPOT_NEWSLETTER_SUBSCRIPTION_TYPE_ID, HUBSPOT_LIFECYCLE_STAGE_PROPERTY, CRM_UNSUBSCRIBE_LEGAL_BASIS, CRM_UNSUBSCRIBE_LEGAL_BASIS_EXPLANATION.

Environment

CRM_QUEUE_URL=
INCOMING_EMAIL_SQS_QUEUE_URL=
EMAIL_VARIATION_QUEUE_URL=
AWS_REGION=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
HUBSPOT_WEBHOOK_CLIENT_SECRET=
HUBSPOT_PRIVATE_APP_TOKEN=