# HubSpot CRM Connector

This connector pack supplies the NCRM-INT-08 outbound callout profiles for
HubSpot CRM batch reads, contact writes, and the complete company/deal write
family (including deal-company association effects), plus the NCRM-INT-07
inbound webhook signature and envelope parser declarations, the NCRM-INT-09
inbound route declarations, and the HubSpot workflow-action execute endpoint
definition. It is L1 pack configuration only: no HubSpot client code, tokens,
tenant mappings, Flow bodies, field mappings, or record writes live here.

## Components

- `connector_rate_limit`: `hubspot.crm.objects.read` and
  `hubspot.crm.objects.write`, conservative throttle policy rows for HubSpot
  CRM object fetches and synchronous object pushes. The out-of-band
  credential definition must be provisioned with `OwnerPack =
  hubspot-crm-connector`; see
  [Credential Provisioning](#credential-provisioning).
- `connector_callout_profile`: `hubspot-crm`, the outbound/shared credential and
  throttle profile (endpointClass `hubspot.crm.objects.read`) consumed by the
  generic INT-10 outbound callout engine. Per L0 design #2003 the inbound
  batch-read topology now lives in the four single-object profiles below.
- `connector_callout_profile`: `hubspot-crm-contacts`, `hubspot-crm-companies`,
  `hubspot-crm-deals`, `hubspot-crm-tickets`, single-object inbound batch-read
  profiles (exactly one executable `objectPathMap` entry each) that carry the
  `batchSpec` projection contract for inbound enrichment.
- `connector_callout_profile`: `hubspot-crm-contact-write`, the explicit
  contact create/update profile consumed by tenant overlay `PushToConnector`
  Flows.
- `connector_callout_profile`: `hubspot-crm-deal-company-write`, the explicit
  company create/update and deal create/update authority, including the
  deal-company association callout required by Opportunity synchronization.
- `webhook_secret`: `webhook`, the pack-owned HubSpot inbound webhook secret
  definition. It stores no secret bytes; operators provision secret values
  outside the pack.
- `webhook_secret`: `workflow-action`, the separate endpoint identity for
  HubSpot Custom Workflow Action execute callbacks. It stores no secret bytes.
- `connector_capability`: `signature_verifier`, declaring that this pack
  supplies a signature verifier handler config.
- `connector_inbound_handler_config`: `signature_verifier`, using endpoint-
  specific generic handlers: HubSpot CRM object webhooks use the legacy
  `X-HubSpot-Signature` SHA-256 secret-prefix contract, while workflow actions
  retain the v3 HMAC contract.
- `connector_capability`: `envelope_parser`, declaring that this pack supplies
  an envelope parser handler config.
- `connector_inbound_handler_config`: `envelope_parser`, mapping HubSpot root
  array subscription events and singleton workflow-action execute payloads into
  generic connector event kinds.
- `connector_inbound_route`: contact creation/property-change plus company and
  deal creation/property-change route declarations. Contact, company, and deal
  deletion events have no default write route. The target Flow bodies and field
  mappings are tenant overlay payload; this pack only declares the route
  contract.

The pack intentionally does not declare a NamedCredential component. Current
NexusCRM substrate provisions NamedCredential definitions through the admin
service; pack-owned NamedCredential authoring is a future substrate gap.

The pack intentionally does not declare Flow config, field mappings, or record
sync behavior. Those rows live in tenant overlay/app-pack payload outside this
repository. This connector pack owns the callable HubSpot profile, credential
reference, handler config, and route identity.

## Runtime Compatibility

This pack requires a NexusCRM L0 build that supports source-backed
`connector_callout_profile` components in `connector-pack` manifests. The
outbound tenant overlay additionally requires the #1863 L0 substrate:
literal cross-pack `profileOwnerPack` resolution for `PushToConnector`,
pack-authored outbound mappings from the executing app pack, and
direction-aware local schema validation for outbound connector maps. It was
validated against NexusCRM `origin/main` head
`2192a326e2e180ec493f99747e1b0e8d2b93ee40` (after #1863). Older local
checkouts that only support `connector_rate_limit` will fail `validate-pack`
with `component.kind.unsupported` for `connector_callout_profile`.

Inbound usage additionally requires an L0 build that supports source-backed
`webhook_secret`, `connector_capability`, and
`connector_inbound_handler_config` components plus the generic
`sha256-secret-prefix-template` scheme. CRM object webhook subscriptions use
HubSpot's documented v1 signature over `clientSecret + rawBody`; the separate
workflow-action endpoint retains v3 and therefore still requires
`WEBHOOK_BASE_URL` to match the exact public callback base URL.

The `contact.propertychange` route uses
`latest_by_source_full_record_preserve_payload_settled`. It requires
`github.com/pingcap-inc/nexuscrm-connectors` `v0.1.10` or later, which is
available on NexusCRM main at `4b16c22222e26ca7eb021822054efd8a3ef44909`
(NexusCRM #3547). Do not apply this route revision to an older L0 build: that
build does not recognize the settled invocation mode.

## Read Profile Contract

- Credential reference: `HubSpotPrivateApp`.
- Base endpoint: supplied by the NamedCredential as `https://api.hubapi.com`.
- Declarative object path map:
  - `/crm/v3/objects/contacts/batch/read`
  - `/crm/v3/objects/companies/batch/read`
  - `/crm/v3/objects/deals/batch/read`
  - `/crm/v3/objects/tickets/batch/read`
- Retry policy: 3 attempts, 1s base delay, 2.0 multiplier, 30s max delay,
  retrying 429, 500, 502, and 503 until the profile contract is explicitly
  widened.

Each single-object inbound profile declares a `batchSpec` for inbound enrichment (NexusCRM #63). When a
webhook delivers only an `objectId`, the inbound worker calls
`Engine.BatchRead` using the batchSpec to fetch HubSpot object properties before
applying connector field mappings. This L1 pack declares the HubSpot request
shape only: `projectionBodyField: "properties"` tells L0 where to merge
projection fields, and downstream app/tenant packs own the actual property
inventory through inbound `connector_field_mapping.sourceField` rows. The
`recordFlattenKey: "properties"` flattens each HubSpot record's `properties`
sub-object so downstream mapping `sourceField` values resolve at the top level
of the enriched record.

The batchSpec `relativePath` is `/read`, appended to each `objectPathMap`
entry (e.g. `/crm/v3/objects/contacts/batch` + `/read` =
`/crm/v3/objects/contacts/batch/read`) by the `joinRelativePath` helper in the
L0 inbound adapter. The `objectPathMap` entries therefore end in `/batch`
rather than `/batch/read`.

The `batchSpec` feature depends on pingcap-inc/NexusCRM#1760
(pingcap-inc/NexusCRM#1756), which adds
`BatchSpec.ExtraBodyFields` and `RecordFlattenKey` support. Dynamic projection
from companion inbound mappings additionally requires the L0
`projectionBodyField` support introduced after pingcap-inc/NexusCRM#1760. This pack JSON is inert
until those L0 changes deploy.

HubSpot `after`/`limit` pagination applies to GET list endpoints, not these
batch-read paths, so this pack does not declare `pageSpec`. This pack does not claim greenfield batch/page execution.

## Write Profile Contracts

- `hubspot-crm-contact-write` explicitly authorizes exact
  `POST /crm/v3/objects/contacts/search` admission plus contact create/update.
- `hubspot-crm-deal-company-write` explicitly authorizes company create/update,
  exact `POST /crm/v3/objects/companies/search` admission, deal create/update,
  and the deal-to-company association `PUT` path template.
- Both profiles use credential reference `HubSpotPrivateApp`, endpoint class
  `hubspot.crm.objects.write`, and a one-attempt profile policy. The generic L0
  MatchBeforeCreate interpreter owns its fixed, bounded three-attempt durable
  retry lifecycle for the read-only Contact or Company search independently of
  this profile budget. Contact, Company, and Deal mutations therefore remain
  one-shot and fail closed; an uncertain mutation is never automatically
  repeated. Durable retry, idempotency, checkpoint, and writeback behavior
  remains owned by the generic L0 outbound protocol.
- `objectPathMap` remains an admin-readable provider path inventory. The
  structured `outboundCapabilities` list is the fail-closed Catalog authority:
  target object, operation, HTTP method, and path template must all agree with
  the selected durable push contract, including any match-before-create search,
  before Catalog publishes it.

The write profiles are inert without tenant overlay payload that owns
`RecordAfterSave` Flows and outbound mappings. That payload calls
`PushToConnector` with literal `profileOwnerPack = hubspot-crm-connector`.

HubSpot references used for this profile:

- [Using Object APIs](https://developers.hubspot.com/docs/api-reference/latest/crm/using-object-apis):
  batch read path, request body shape, and 100-input batch limit.
- [Contacts API guide](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/guide):
  contacts batch read and list-endpoint `limit`, `after`, and
  `paging.next.after` behavior.
- [API usage guidelines and limits](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines):
  private app 10-second rate limits and 429 behavior.
- [Legacy private apps](https://developers.hubspot.com/docs/api/private-apps):
  private app access tokens sent through the `Authorization: Bearer` header.

## Inbound Webhook Contract

- Webhook secret definition: `hubspot-crm-connector/webhook`.
- Signature verifier capability and handler config:
  `hubspot-crm-connector:signature_verifier`.
- Signature scheme: `sha256-secret-prefix-template`.
- Signing template: `{RawBody}`; the scheme prepends the resolved client secret.
- Signature header: `X-HubSpot-Signature`.
- Signature encoding: `hex`.
- Verified ingress receipts: enabled. Delivery identity is derived from the
  signed root event collection using `deliveryIdentityCollectionPath: "."` and
  ordered `deliveryIdentityCollectionKeyPaths` over `.portalId`, `.appId`,
  `.subscriptionId`, `.eventId`, `.occurredAt`, and `.subscriptionType`.
  `.attemptNumber` is also included so a provider retry receives a distinct
  durable receipt while the envelope Event identity continues to ignore retry
  attempts and deduplicate the business event.
- Secret name: `webhook`.
- Envelope parser capability and handler config:
  `hubspot-crm-connector:envelope_parser`.
- Event array path: `.`, because HubSpot sends webhook notifications as a root
  array.

The envelope parser maps HubSpot `contact`, `company`, `deal`, and `ticket`
subscription events for creation, property changes, deletion, association
changes, merges, and restores. Contact privacy deletion is also declared. Event
kinds stay object-specific where route declarations target them by object.
All rules use `.eventId` as the idempotency key. Association-change events use
`.fromObjectId` as the source id; other events use `.objectId`. The
`payloadProjection` preserves common HubSpot webhook fields, including merge
and privacy-deletion fields when present.

HubSpot generic CRM webhook subscriptions use `subscriptionType=object.*` with
`objectTypeId` selecting the CRM object. This pack declares a composite selector
for Contact creation that requires both `subscriptionType=object.creation` and
`objectTypeId=0-1`, routing it to `contact.creation`; it does not accept Company
or Deal creation. It also declares composite selector rules for generic merge
and property-change notifications: `subscriptionType=object.merge` or
`subscriptionType=object.propertyChange` with object type ids `0-1` (contacts),
`0-2` (companies), or `0-3` (deals).
Generic association subscriptions remain routed through typed HubSpot
subscription names. Deletion events are parsed into object-specific event kinds
for observability/extension, but this pack intentionally declares no default deletion write route for contacts, companies, or deals.

## Workflow Action Execute Contract

- Webhook secret definition: `hubspot-crm-connector/workflow-action`.
- Execute URL:
  `<NEXUSCRM_WEBHOOK_BASE_URL>/webhooks/hubspot-crm-connector/workflow-action`.
- Signature verifier: HubSpot v3 `hmac-sha256-template`, endpoint-scoped to
  `workflow-action` and independent from the CRM webhook v1 contract.
- Signing template: `{Method}{RequestURL}{RawBody}{Timestamp}`; the public
  callback URL therefore remains part of workflow-action signature validation.
- Verified ingress receipts: enabled. Delivery identity is derived from the
  signed singleton payload using `deliveryIdentityBodyPath: .callbackId`.
- Envelope parser endpoint mode: `payload_only_allowed`; the endpoint uses
  `eventObjectPath: "."` because HubSpot sends one execute callback object, not
  an array of subscription events.
- Event kind: `workflow.action.execute`.
- Object kind: `workflow-actions`.
- Source id and default mutation lane: `.object.objectId`. Executions for the
  same HubSpot contact remain ordered, while different contacts targeting the
  same campaign can drain concurrently.
- Idempotency key: `.callbackId`, scoped by the generic dispatcher to the
  `workflow-action` endpoint. HubSpot's custom workflow action reference
  defines `callbackId` as the unique id for the specific execution; `portalId`
  remains projected payload context, not a second idempotency-key source.
- Route target: `HubSpot_Workflow_Action_CampaignMember_Upsert`.

The workflow-action payload projection preserves `callbackId`, `portalId`,
`objectId`, `objectType`, `enrollmentId`, `workflowId`, `actionId`,
`actionDefinitionId`, and
`inputFields.{campaign_id,status,email,firstname,lastname,company,
country_picklist__sfdc_,hubspot_owner_id,jobtitle,leadsource,lifecyclestage,
no__of_times_mql,hs_lead_status,salesforce_industry,recent_source,
recent_source_details,lead_source_details}`. HubSpot does not include contact
properties by default; the action definition requires admins to bind `email` to
the Contact Email object property and can bind the optional contact hydrate
fields when downstream campaign-member shell creation or existing-shell
rehydration needs those contact properties before the full contact inbound path
runs.

The HubSpot action definition artifact is
`workflow_actions/add_to_nexuscrm_campaign.action.json`. It intentionally keeps
`published: false` and uses placeholders for public URLs and option-provider
tokens. Per pingcap-inc/NexusCRM#1949, Campaign and status option URLs point to
an external HubSpot-side adapter, not a NexusCRM generic option-provider
endpoint. The adapter must read Campaign and CampaignMemberStatus facts through
normal authenticated NexusCRM APIs and must not store a second copy of those
facts.

## Inbound Routes

Routes declared in this pack:

- `contact.creation` -> `HubSpot_Contact_Lead_Upsert`
- `contact.propertychange` -> `HubSpot_Contact_Lead_Upsert`
- `workflow.action.execute` -> `HubSpot_Workflow_Action_CampaignMember_Upsert`

The target Flows and HubSpot field-mapping rows are in
tenant overlay/app-pack payload outside this repository. This split matches the
current L0 contract: connector packs own connector identity and routes; tenant
payload owns Flow and connector field-mapping rows.

## Credential Provisioning

Before using this profile, a setup operator provisions the out-of-band
NamedCredential definition with the connector pack as `OwnerPack`. The same
statement is available at
`runbooks/provision_named_credential.sql`:

```sql
INSERT INTO _System_NamedCredential
    (Id, DeveloperName, Label, Endpoint, PrincipalType, AuthType,
     Description, OwnerPack, CreatedById, CreatedDate, LastModifiedDate)
VALUES
    (
      'nc_hubspot_crm_connector_private_app',
      'HubSpotPrivateApp',
      'HubSpot Private App',
      'https://api.hubapi.com',
      'NamedPrincipal',
      'BearerToken',
      'HubSpot private app token for the hubspot-crm-connector profile.',
      'hubspot-crm-connector',
      NULL,
      CURRENT_TIMESTAMP,
      CURRENT_TIMESTAMP
    )
ON DUPLICATE KEY UPDATE
    Label = VALUES(Label),
    Endpoint = VALUES(Endpoint),
    PrincipalType = VALUES(PrincipalType),
    AuthType = VALUES(AuthType),
    Description = VALUES(Description),
    OwnerPack = VALUES(OwnerPack),
    LastModifiedDate = VALUES(LastModifiedDate);
```

Then an operator with `ManageNamedCredentials` writes or rotates the secret
value through the NamedCredential service:

```go
svc.SetSecret(ctx, adminUser, "HubSpotPrivateApp", "Token", secretManagerValue)
```

Do not put the private app token in this repository. The token value belongs in
the encrypted NamedCredential secret store at install time.

Do not use `namedcredential.Service.UpsertDefinition` for this definition in
the current L0 build. That admin path intentionally clears `OwnerPack`; if it
is used here, `ResolveCredentialPack` returns an empty namespace and the
runtime throttler cannot read this pack's `connector_rate_limit` row. The SQL
above is a setup/operator runbook for the missing pack-owned NamedCredential
writer; the credential is not a pack component and is not included in this
pack payload hash.

## Sibling Scope

NCRM-INT-08 owns the outbound callout profile and endpoint-class policy.
NCRM-INT-07 owns the inbound webhook secret, signature verifier, and envelope
parser declarations in this pack, using the generic NCRM-INT-11 runtime.
NCRM-INT-09 owns the route declarations in this pack; tenant overlay payload
owns the target Flow bodies and field mappings.
