@particle-academy/hubspot-js 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,75 @@ The four packages share one version, because they are generated from one
7
7
  `provider/` definition and a version that meant something different in each
8
8
  would be a version nobody could reason about.
9
9
 
10
+ ## [0.6.0] — 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - **`contact_create` takes a `properties` map, as `company_create` and
15
+ `deal_create` do**, so a contact is created with ANY property (`jobtitle`,
16
+ an address, a custom property) in one call. Additive: the named fields
17
+ (Email, First name, Last name, Company, Phone, Lifecycle stage) work
18
+ exactly as before and merge into the map. `email` is still required, and
19
+ may be given in Email OR as `email` in Properties; missing from both, the
20
+ refusal names both places. The same property given both ways with
21
+ DIFFERENT values is refused by name rather than guessed. The editor no
22
+ longer marks Email required, because the map can satisfy it. Found by MOIC
23
+ (their #905): `properties` given the company_create way was refused as a
24
+ missing email.
25
+
26
+ ### Fixed
27
+
28
+ - **PHP: a `properties` map passed as an OBJECT is read, not dropped.** The
29
+ generated PHP read a map that arrived as a decoded JSON OBJECT (`stdClass`,
30
+ e.g. an upstream node's value or `json_decode(..., false)`) as "not an
31
+ array" and sent it EMPTY, without an error: silent data loss. It now reads
32
+ the object's properties. **Affected:** PHP callers passing the map as an
33
+ object. **Not affected:** callers passing an array, which always worked;
34
+ they need do nothing. TypeScript and Python were never affected.
35
+ - **`crm_event` no longer tells a host to dedupe on `eventId`.** HubSpot's
36
+ field table says `eventId` "is not guaranteed to be unique", so deduping on
37
+ it can drop a DIFFERENT event that shares one, such as two properties of one
38
+ deal changing together. The setup text and the `eventId` / `attemptNumber`
39
+ descriptions now say what a retry is: the same event resent, differing only
40
+ in `attemptNumber`, so a host compares every other field. Found by MOIC.
41
+ - **The setup guidance now says a host does not copy this connector's scope
42
+ list into its consent URL.** The list covers every action, read and write.
43
+ HubSpot requires the scopes an app marks required in the authorize URL's
44
+ `scope` parameter and shows an error otherwise, so a read-only host asks for
45
+ its read scopes only. On the `accessToken` credential's help and in
46
+ `crm_event`'s setup. Found by MOIC. The list itself is unchanged.
47
+
48
+ ## [0.5.0] — 2026-11-01
49
+
50
+ ### Added
51
+
52
+ - **`crm_event`: HubSpot's CRM webhooks, verified.** HubSpot pushes a BATCH of
53
+ events (deal, contact and company created, property changed, deleted,
54
+ merged, restored, re-associated; which ones is set in the HubSpot app) to
55
+ the app's target URL. Each delivery is verified with **signature v3**
56
+ before anything reads it: base64 HMAC-SHA256 of method + URI + body +
57
+ timestamp under the app's `clientSecret`. The timestamp
58
+ (`X-HubSpot-Request-Timestamp`) is in MILLISECONDS, with a 5-minute window,
59
+ and the URI has only its query percent-decoded. HubSpot's own published
60
+ example verifies in TypeScript, PHP and Python; a changed byte, another
61
+ method, a late delivery and a missing request line are each refused.
62
+ - **The host must pass the request line.** `verifyHubspotDelivery({raw,
63
+ headers, method, url})` (js), `CrmEvent::verifyDelivery($raw, $headers,
64
+ $secret, method: …, url: …)` (php) and `verify_delivery(raw, headers,
65
+ secret, method=…, url=…)` (python) take the URL exactly as it arrived.
66
+ Without them a delivery is refused by name, never verified with a gap.
67
+ - **Routing is DECLARED** (`route: {by: "portalId", path: "[].portalId"}`,
68
+ on the descriptor, as `ROUTE`, and in the registry index). One URL serves
69
+ every portal that installed the app, so route each event by `portalId`
70
+ ONLY after the signature verifies, and only to a connection minted when
71
+ that portal installed. Drop an unmapped portal with a 2xx and a log line,
72
+ and create nothing.
73
+
74
+ ### Changed
75
+
76
+ - **Requires `fancy-connector-core` ≥ 0.12.0**, which verifies a signed
77
+ request line and millisecond timestamps.
78
+
10
79
  ## [0.4.0] — 2026-11-01
11
80
 
12
81
  ### Added
package/README.md CHANGED
@@ -43,7 +43,7 @@ A HubSpot connection holds 4 values.
43
43
  |---|---|---|---|
44
44
  | **App client ID** | per installation | not secret | From your HubSpot app's Auth tab. ONE value for the whole installation -- the same app serves every connected account. |
45
45
  | **App client secret** | per installation | **secret** | From the same Auth tab. Used to exchange the code and to refresh; never sent with an API request. |
46
- | **Access token** | per connected account | **secret** | Obtained by the host's OAuth exchange, PER CONNECTED ACCOUNT. Expires after 30 minutes -- a host that does not refresh will work all afternoon and be broken by morning. |
46
+ | **Access token** | per connected account | **secret** | Obtained by the host's OAuth exchange, PER CONNECTED ACCOUNT. Expires after 30 minutes -- a host that does not refresh will work all afternoon and be broken by morning. This connector's scope list covers every action, read and write, and is not what your consent URL must ask for: HubSpot requires your app's required scopes in the authorize URL's `scope` parameter and shows an error otherwise, so build it from your own app's configuration. A read-only host asks for its read scopes only. |
47
47
  | **Refresh token** *(optional)* | per connected account | **secret** | Also per account. Does not expire, so it is the credential worth protecting most: it mints access tokens indefinitely until the install is revoked. |
48
48
 
49
49
  ### Authorising
@@ -136,12 +136,13 @@ Create a contact in HubSpot.
136
136
 
137
137
  | Input | Required | What it is |
138
138
  |---|---|---|
139
- | `email` | yes | HubSpot de-duplicates contacts on email within an account. A contact created without one cannot be de-duplicated, and a retry will make a second. |
139
+ | `email` | yes | HubSpot de-duplicates contacts on email within an account. A contact created without one cannot be de-duplicated, and a retry will make a second. Required here OR as email in Properties. |
140
140
  | `firstName` | no | First name |
141
141
  | `lastName` | no | Last name |
142
142
  | `company` | no | Company |
143
143
  | `phone` | no | Phone |
144
144
  | `lifecycleStage` | no | HubSpot's own stage names. A value it does not recognise is refused at write time, not at setup. |
145
+ | `properties` | no | Any other HubSpot property name → value for the new contact (jobtitle, an address, a custom property), sent in the same call. Values are sent as strings, which is how HubSpot stores every property. The fields above merge into this map; email may be given here instead of in Email, and the same property given both ways with different values is refused rather than guessed. |
145
146
 
146
147
  #### `contact_get` — Get contact
147
148
 
@@ -285,6 +286,18 @@ Every property defined on deals, contacts or companies: name, label, type and th
285
286
  |---|---|---|
286
287
  | `objectType` | yes | Object |
287
288
 
289
+ ### Triggers
290
+
291
+ #### `crm_event` — HubSpot CRM event
292
+
293
+ Start when HubSpot reports deals, contacts or companies created, changed, deleted, merged, restored or re-associated.
294
+
295
+ Delivered by webhook, and the signature is verified before anything runs.
296
+
297
+ **You have to set this up with the provider first:**
298
+
299
+ In the HubSpot app's webhook settings, set the target URL to the route your host mounts for this trigger and subscribe to the deal, contact and company events you want. Deliveries are signed with the app's client secret, already on the connection as `clientSecret`. ONE URL serves every portal that installed the app, so route each event by its portalId, and only after the signature has verified: map portalId to the connection created when that portal installed, and drop an unmapped portalId with a 2xx and a log line, creating nothing. HubSpot retries a failed delivery, and eventId is not guaranteed to be unique, so never dedupe on eventId alone: a retry is the same event resent, differing only in attemptNumber, so compare every other field. This connector's scope list covers every action, read and write, and is not what your consent URL must ask for: HubSpot requires your app's required scopes in the authorize URL's `scope` parameter and shows an error otherwise, so build it from your own app's configuration. A read-only host asks for its read scopes only.
300
+
288
301
  ## Run it before you have credentials
289
302
 
290
303
  Every operation ships a **faker**, whether or not HubSpot has a sandbox. Set a
@@ -1,4 +1,4 @@
1
- import { callConnector } from '@particle-academy/fancy-connector-core';
1
+ import { callConnector, verifyDelivery } from '@particle-academy/fancy-connector-core';
2
2
 
3
3
  // src/faker.ts
4
4
  function fakeCompanyCreate({ config, fake }) {
@@ -392,6 +392,37 @@ function fakePropertyList({ config, fake }) {
392
392
  ]
393
393
  };
394
394
  }
395
+ function fakeCrmEvent({ config, fake }) {
396
+ return [
397
+ {
398
+ "eventId": 531833541,
399
+ "subscriptionId": 3923621,
400
+ "portalId": 48807704,
401
+ "appId": 16111050,
402
+ "occurredAt": 1752613920733,
403
+ "subscriptionType": "contact.creation",
404
+ "attemptNumber": 0,
405
+ "objectId": 138017612137,
406
+ "changeFlag": "CREATED",
407
+ "changeSource": "CRM_UI",
408
+ "sourceId": "userId:76023669"
409
+ },
410
+ {
411
+ "eventId": 531833542,
412
+ "subscriptionId": 3923622,
413
+ "portalId": 48807704,
414
+ "appId": 16111050,
415
+ "occurredAt": 1752613921e3,
416
+ "subscriptionType": "deal.propertyChange",
417
+ "attemptNumber": 0,
418
+ "objectId": 9876543210,
419
+ "propertyName": "dealstage",
420
+ "propertyValue": "closedwon",
421
+ "changeSource": "CRM_UI",
422
+ "sourceId": "userId:76023669"
423
+ }
424
+ ];
425
+ }
395
426
  var hubspotFaker = (operation, request) => {
396
427
  switch (operation) {
397
428
  case "company_create":
@@ -430,6 +461,8 @@ var hubspotFaker = (operation, request) => {
430
461
  return fakePipelineList(request);
431
462
  case "property_list":
432
463
  return fakePropertyList(request);
464
+ case "crm_event":
465
+ return fakeCrmEvent(request);
433
466
  default:
434
467
  throw new Error(
435
468
  `hubspot: no fake response is defined for "${operation}". Add a fixture under provider/fixtures/ and regenerate \u2014 a connector without a faker cannot be developed against, tested, or demonstrated.`
@@ -640,8 +673,44 @@ function propertiesMap2(value) {
640
673
  var CONTACT_CREATE_OPERATION = "contact_create";
641
674
  async function hubspotContactCreate(options) {
642
675
  const config = options.config ?? {};
643
- if (config.email === void 0 || config.email === null || config.email === "") {
644
- throw new Error(`contact_create: "email" is required (Email).`);
676
+ if ((config.email === void 0 || config.email === null || config.email === "") && propertiesMap3(config.properties)["email"] === void 0) {
677
+ throw new Error(`contact_create: "email" is required \u2014 as Email, or as "email" in Properties.`);
678
+ }
679
+ {
680
+ const inMap = propertiesMap3(config.properties)["email"];
681
+ if (config.email !== void 0 && config.email !== null && config.email !== "" && inMap !== void 0 && inMap !== String(config.email).trim()) {
682
+ throw new Error(`contact_create: "email" was given twice with different values \u2014 as Email and as "email" in Properties.`);
683
+ }
684
+ }
685
+ {
686
+ const inMap = propertiesMap3(config.properties)["firstname"];
687
+ if (config.firstName !== void 0 && config.firstName !== null && config.firstName !== "" && inMap !== void 0 && inMap !== String(config.firstName)) {
688
+ throw new Error(`contact_create: "firstName" was given twice with different values \u2014 as First name and as "firstname" in Properties.`);
689
+ }
690
+ }
691
+ {
692
+ const inMap = propertiesMap3(config.properties)["lastname"];
693
+ if (config.lastName !== void 0 && config.lastName !== null && config.lastName !== "" && inMap !== void 0 && inMap !== String(config.lastName)) {
694
+ throw new Error(`contact_create: "lastName" was given twice with different values \u2014 as Last name and as "lastname" in Properties.`);
695
+ }
696
+ }
697
+ {
698
+ const inMap = propertiesMap3(config.properties)["company"];
699
+ if (config.company !== void 0 && config.company !== null && config.company !== "" && inMap !== void 0 && inMap !== String(config.company)) {
700
+ throw new Error(`contact_create: "company" was given twice with different values \u2014 as Company and as "company" in Properties.`);
701
+ }
702
+ }
703
+ {
704
+ const inMap = propertiesMap3(config.properties)["phone"];
705
+ if (config.phone !== void 0 && config.phone !== null && config.phone !== "" && inMap !== void 0 && inMap !== String(config.phone)) {
706
+ throw new Error(`contact_create: "phone" was given twice with different values \u2014 as Phone and as "phone" in Properties.`);
707
+ }
708
+ }
709
+ {
710
+ const inMap = propertiesMap3(config.properties)["lifecyclestage"];
711
+ if (config.lifecycleStage !== void 0 && config.lifecycleStage !== null && config.lifecycleStage !== "" && inMap !== void 0 && inMap !== String(config.lifecycleStage)) {
712
+ throw new Error(`contact_create: "lifecycleStage" was given twice with different values \u2014 as Lifecycle stage and as "lifecyclestage" in Properties.`);
713
+ }
645
714
  }
646
715
  return callConnector(HUBSPOT, {
647
716
  operation: CONTACT_CREATE_OPERATION,
@@ -655,15 +724,15 @@ async function hubspotContactCreate(options) {
655
724
  request: {
656
725
  method: "POST",
657
726
  path: "/crm/v3/objects/contacts",
658
- json: nestFields({
659
- "properties.email": String(config.email).trim(),
727
+ json: mergeInto(nestFields({
728
+ ...config.email !== void 0 && config.email !== null && config.email !== "" ? { "properties.email": String(config.email).trim() } : {},
660
729
  ...config.firstName !== void 0 && config.firstName !== null && config.firstName !== "" ? { "properties.firstname": String(config.firstName) } : {},
661
730
  ...config.lastName !== void 0 && config.lastName !== null && config.lastName !== "" ? { "properties.lastname": String(config.lastName) } : {},
662
731
  ...config.company !== void 0 && config.company !== null && config.company !== "" ? { "properties.company": String(config.company) } : {},
663
732
  ...config.phone !== void 0 && config.phone !== null && config.phone !== "" ? { "properties.phone": String(config.phone) } : {},
664
733
  ...config.lifecycleStage !== void 0 && config.lifecycleStage !== null && config.lifecycleStage !== "" ? { "properties.lifecyclestage": String(config.lifecycleStage) } : {},
665
734
  "associations": []
666
- })
735
+ }), "properties", propertiesMap3(config.properties))
667
736
  }
668
737
  });
669
738
  }
@@ -682,6 +751,18 @@ function nestFields(flat) {
682
751
  }
683
752
  return out;
684
753
  }
754
+ function mergeInto(body, key, map) {
755
+ const named = body[key];
756
+ return { ...body, [key]: { ...map, ...named !== null && typeof named === "object" ? named : {} } };
757
+ }
758
+ function propertiesMap3(value) {
759
+ if (!value || typeof value !== "object") return {};
760
+ const out = {};
761
+ for (const [key, item] of Object.entries(value)) {
762
+ if (item !== void 0 && item !== null && item !== "") out[key] = String(item);
763
+ }
764
+ return out;
765
+ }
685
766
  var CONTACT_GET_OPERATION = "contact_get";
686
767
  async function hubspotContactGet(options) {
687
768
  const config = options.config ?? {};
@@ -815,12 +896,12 @@ async function hubspotContactUpdate(options) {
815
896
  method: "PATCH",
816
897
  path: `/crm/v3/objects/contacts/${encodeURIComponent(String(config.contactId))}`,
817
898
  json: {
818
- "properties": propertiesMap3(config.properties)
899
+ "properties": propertiesMap4(config.properties)
819
900
  }
820
901
  }
821
902
  });
822
903
  }
823
- function propertiesMap3(value) {
904
+ function propertiesMap4(value) {
824
905
  if (!value || typeof value !== "object") return {};
825
906
  const out = {};
826
907
  for (const [key, item] of Object.entries(value)) {
@@ -847,13 +928,13 @@ async function hubspotDealCreate(options) {
847
928
  method: "POST",
848
929
  path: "/crm/v3/objects/deals",
849
930
  json: {
850
- "properties": propertiesMap4(config.properties),
931
+ "properties": propertiesMap5(config.properties),
851
932
  "associations": []
852
933
  }
853
934
  }
854
935
  });
855
936
  }
856
- function propertiesMap4(value) {
937
+ function propertiesMap5(value) {
857
938
  if (!value || typeof value !== "object") return {};
858
939
  const out = {};
859
940
  for (const [key, item] of Object.entries(value)) {
@@ -994,12 +1075,12 @@ async function hubspotDealUpdate(options) {
994
1075
  method: "PATCH",
995
1076
  path: `/crm/v3/objects/deals/${encodeURIComponent(String(config.dealId))}`,
996
1077
  json: {
997
- "properties": propertiesMap5(config.properties)
1078
+ "properties": propertiesMap6(config.properties)
998
1079
  }
999
1080
  }
1000
1081
  });
1001
1082
  }
1002
- function propertiesMap5(value) {
1083
+ function propertiesMap6(value) {
1003
1084
  if (!value || typeof value !== "object") return {};
1004
1085
  const out = {};
1005
1086
  for (const [key, item] of Object.entries(value)) {
@@ -1080,7 +1161,34 @@ async function hubspotPropertyList(options) {
1080
1161
  }
1081
1162
  });
1082
1163
  }
1164
+ var HUBSPOT_CRM_EVENT_SIGNATURE_HEADER = "X-HubSpot-Signature-v3";
1165
+ var HUBSPOT_CRM_EVENT_TOLERANCE = 300;
1166
+ var HUBSPOT_CRM_EVENT_SCHEME = {
1167
+ algorithm: "SHA-256",
1168
+ payload: (raw, timestamp, _id, request) => `${request?.method ?? ""}${request?.uri ?? ""}${raw}${timestamp}`,
1169
+ tolerance: HUBSPOT_CRM_EVENT_TOLERANCE,
1170
+ timestampUnit: "ms",
1171
+ signsRequestLine: true,
1172
+ uri: "hubspot-v3",
1173
+ encoding: "base64"
1174
+ };
1175
+ var HUBSPOT_CRM_EVENT = {
1176
+ service: "hubspot",
1177
+ operation: "crm_event",
1178
+ delivery: "webhook",
1179
+ setup: "In the HubSpot app's webhook settings, set the target URL to the route your host mounts for this trigger and subscribe to the deal, contact and company events you want. Deliveries are signed with the app's client secret, already on the connection as `clientSecret`. ONE URL serves every portal that installed the app, so route each event by its portalId, and only after the signature has verified: map portalId to the connection created when that portal installed, and drop an unmapped portalId with a 2xx and a log line, creating nothing. HubSpot retries a failed delivery, and eventId is not guaranteed to be unique, so never dedupe on eventId alone: a retry is the same event resent, differing only in attemptNumber, so compare every other field. This connector's scope list covers every action, read and write, and is not what your consent URL must ask for: HubSpot requires your app's required scopes in the authorize URL's `scope` parameter and shows an error otherwise, so build it from your own app's configuration. A read-only host asks for its read scopes only.",
1180
+ verification: {
1181
+ signatureHeader: HUBSPOT_CRM_EVENT_SIGNATURE_HEADER,
1182
+ scheme: HUBSPOT_CRM_EVENT_SCHEME,
1183
+ timestampHeader: "X-HubSpot-Request-Timestamp"
1184
+ },
1185
+ route: { by: "portalId", path: "[].portalId" },
1186
+ faker: hubspotFaker
1187
+ };
1188
+ function verifyHubspotDelivery(delivery, clientSecret, now) {
1189
+ return verifyDelivery(HUBSPOT_CRM_EVENT, delivery, clientSecret, now);
1190
+ }
1083
1191
 
1084
- export { COMPANY_CREATE_OPERATION, COMPANY_GET_OPERATION, COMPANY_LIST_OPERATION, COMPANY_SEARCH_OPERATION, COMPANY_UPDATE_OPERATION, CONNECTOR_API_VERSION, CONTACT_CREATE_OPERATION, CONTACT_GET_OPERATION, CONTACT_LIST_OPERATION, CONTACT_SEARCH_OPERATION, CONTACT_UPDATE_OPERATION, DEAL_CREATE_OPERATION, DEAL_GET_OPERATION, DEAL_LIST_OPERATION, DEAL_SEARCH_OPERATION, DEAL_UPDATE_OPERATION, HUBSPOT, HUBSPOT_BASE_URLS, HUBSPOT_REQUIRES, OWNER_LIST_OPERATION, PIPELINE_LIST_OPERATION, PROPERTY_LIST_OPERATION, hubspotAuthorize, hubspotCompanyCreate, hubspotCompanyGet, hubspotCompanyList, hubspotCompanySearch, hubspotCompanyUpdate, hubspotContactCreate, hubspotContactGet, hubspotContactList, hubspotContactSearch, hubspotContactUpdate, hubspotDealCreate, hubspotDealGet, hubspotDealList, hubspotDealSearch, hubspotDealUpdate, hubspotFaker, hubspotOwnerList, hubspotPipelineList, hubspotPropertyList };
1085
- //# sourceMappingURL=chunk-FFT5OGGS.js.map
1086
- //# sourceMappingURL=chunk-FFT5OGGS.js.map
1192
+ export { COMPANY_CREATE_OPERATION, COMPANY_GET_OPERATION, COMPANY_LIST_OPERATION, COMPANY_SEARCH_OPERATION, COMPANY_UPDATE_OPERATION, CONNECTOR_API_VERSION, CONTACT_CREATE_OPERATION, CONTACT_GET_OPERATION, CONTACT_LIST_OPERATION, CONTACT_SEARCH_OPERATION, CONTACT_UPDATE_OPERATION, DEAL_CREATE_OPERATION, DEAL_GET_OPERATION, DEAL_LIST_OPERATION, DEAL_SEARCH_OPERATION, DEAL_UPDATE_OPERATION, HUBSPOT, HUBSPOT_BASE_URLS, HUBSPOT_CRM_EVENT, HUBSPOT_CRM_EVENT_SCHEME, HUBSPOT_CRM_EVENT_SIGNATURE_HEADER, HUBSPOT_CRM_EVENT_TOLERANCE, HUBSPOT_REQUIRES, OWNER_LIST_OPERATION, PIPELINE_LIST_OPERATION, PROPERTY_LIST_OPERATION, hubspotAuthorize, hubspotCompanyCreate, hubspotCompanyGet, hubspotCompanyList, hubspotCompanySearch, hubspotCompanyUpdate, hubspotContactCreate, hubspotContactGet, hubspotContactList, hubspotContactSearch, hubspotContactUpdate, hubspotDealCreate, hubspotDealGet, hubspotDealList, hubspotDealSearch, hubspotDealUpdate, hubspotFaker, hubspotOwnerList, hubspotPipelineList, hubspotPropertyList, verifyHubspotDelivery };
1193
+ //# sourceMappingURL=chunk-AVMPFAVL.js.map
1194
+ //# sourceMappingURL=chunk-AVMPFAVL.js.map