@shware/analytics 8.16.0 → 9.0.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.
Files changed (90) hide show
  1. package/README.md +28 -4
  2. package/dist/attribution/classify.cjs +6 -6
  3. package/dist/attribution/classify.cjs.map +1 -1
  4. package/dist/attribution/classify.mjs +6 -6
  5. package/dist/attribution/classify.mjs.map +1 -1
  6. package/dist/attribution/vocabulary.cjs +3 -4
  7. package/dist/attribution/vocabulary.cjs.map +1 -1
  8. package/dist/attribution/vocabulary.d.cts +3 -4
  9. package/dist/attribution/vocabulary.d.cts.map +1 -1
  10. package/dist/attribution/vocabulary.d.mts +3 -4
  11. package/dist/attribution/vocabulary.d.mts.map +1 -1
  12. package/dist/attribution/vocabulary.mjs +3 -4
  13. package/dist/attribution/vocabulary.mjs.map +1 -1
  14. package/dist/click-id/index.cjs +24 -6
  15. package/dist/click-id/index.cjs.map +1 -1
  16. package/dist/click-id/index.d.cts +14 -3
  17. package/dist/click-id/index.d.cts.map +1 -1
  18. package/dist/click-id/index.d.mts +14 -3
  19. package/dist/click-id/index.d.mts.map +1 -1
  20. package/dist/click-id/index.mjs +23 -7
  21. package/dist/click-id/index.mjs.map +1 -1
  22. package/dist/schema/index.cjs +25 -9
  23. package/dist/schema/index.cjs.map +1 -1
  24. package/dist/schema/index.d.cts +114 -54
  25. package/dist/schema/index.d.cts.map +1 -1
  26. package/dist/schema/index.d.mts +114 -54
  27. package/dist/schema/index.d.mts.map +1 -1
  28. package/dist/schema/index.mjs +25 -9
  29. package/dist/schema/index.mjs.map +1 -1
  30. package/dist/server/click-ids.cjs +93 -0
  31. package/dist/server/click-ids.cjs.map +1 -0
  32. package/dist/server/click-ids.d.cts +40 -0
  33. package/dist/server/click-ids.d.cts.map +1 -0
  34. package/dist/server/click-ids.d.mts +40 -0
  35. package/dist/server/click-ids.d.mts.map +1 -0
  36. package/dist/server/click-ids.mjs +85 -0
  37. package/dist/server/click-ids.mjs.map +1 -0
  38. package/dist/server/google-data-manager.cjs +2 -1
  39. package/dist/server/google-data-manager.cjs.map +1 -1
  40. package/dist/server/google-data-manager.d.cts.map +1 -1
  41. package/dist/server/google-data-manager.d.mts.map +1 -1
  42. package/dist/server/google-data-manager.mjs +2 -1
  43. package/dist/server/google-data-manager.mjs.map +1 -1
  44. package/dist/server/linkedin-conversions-api.cjs +9 -4
  45. package/dist/server/linkedin-conversions-api.cjs.map +1 -1
  46. package/dist/server/linkedin-conversions-api.d.cts.map +1 -1
  47. package/dist/server/linkedin-conversions-api.d.mts.map +1 -1
  48. package/dist/server/linkedin-conversions-api.mjs +9 -4
  49. package/dist/server/linkedin-conversions-api.mjs.map +1 -1
  50. package/dist/server/meta-capi.cjs +5 -4
  51. package/dist/server/meta-capi.cjs.map +1 -1
  52. package/dist/server/meta-capi.mjs +5 -4
  53. package/dist/server/meta-capi.mjs.map +1 -1
  54. package/dist/server/meta-conversions-api.cjs +5 -4
  55. package/dist/server/meta-conversions-api.cjs.map +1 -1
  56. package/dist/server/meta-conversions-api.d.cts.map +1 -1
  57. package/dist/server/meta-conversions-api.d.mts.map +1 -1
  58. package/dist/server/meta-conversions-api.mjs +5 -4
  59. package/dist/server/meta-conversions-api.mjs.map +1 -1
  60. package/dist/server/microsoft-conversions-api.cjs +3 -1
  61. package/dist/server/microsoft-conversions-api.cjs.map +1 -1
  62. package/dist/server/microsoft-conversions-api.d.cts.map +1 -1
  63. package/dist/server/microsoft-conversions-api.d.mts.map +1 -1
  64. package/dist/server/microsoft-conversions-api.mjs +3 -1
  65. package/dist/server/microsoft-conversions-api.mjs.map +1 -1
  66. package/dist/server/openai-conversions-api.cjs +33 -11
  67. package/dist/server/openai-conversions-api.cjs.map +1 -1
  68. package/dist/server/openai-conversions-api.d.cts +19 -8
  69. package/dist/server/openai-conversions-api.d.cts.map +1 -1
  70. package/dist/server/openai-conversions-api.d.mts +19 -8
  71. package/dist/server/openai-conversions-api.d.mts.map +1 -1
  72. package/dist/server/openai-conversions-api.mjs +33 -11
  73. package/dist/server/openai-conversions-api.mjs.map +1 -1
  74. package/dist/server/reddit-conversions-api.cjs +3 -2
  75. package/dist/server/reddit-conversions-api.cjs.map +1 -1
  76. package/dist/server/reddit-conversions-api.d.cts.map +1 -1
  77. package/dist/server/reddit-conversions-api.d.mts.map +1 -1
  78. package/dist/server/reddit-conversions-api.mjs +3 -2
  79. package/dist/server/reddit-conversions-api.mjs.map +1 -1
  80. package/dist/track/types.d.cts +48 -16
  81. package/dist/track/types.d.cts.map +1 -1
  82. package/dist/track/types.d.mts +48 -16
  83. package/dist/track/types.d.mts.map +1 -1
  84. package/dist/web/index.cjs +17 -11
  85. package/dist/web/index.cjs.map +1 -1
  86. package/dist/web/index.d.cts.map +1 -1
  87. package/dist/web/index.d.mts.map +1 -1
  88. package/dist/web/index.mjs +17 -11
  89. package/dist/web/index.mjs.map +1 -1
  90. package/package.json +1 -1
@@ -0,0 +1,40 @@
1
+ import { TrackTags } from "../track/types.mjs";
2
+ //#region src/server/click-ids.d.ts
3
+ /**
4
+ * Meta's `fbc`: built from this page's `fbclid`, else the `_fbc` cookie. One exception on the
5
+ * landing page: a cookie opened by that same `fbclid` is sent as it is, for its creationTime —
6
+ * `resolveClickIdCookies` sets it on the landing document, before any event of the page.
7
+ */
8
+ export declare function metaFbc(tags: TrackTags, eventTimeMs: number): string | undefined;
9
+ /** Meta's `fbp`: the pixel's browser id, a cookie only. */
10
+ export declare function metaFbp(tags: TrackTags): string | undefined;
11
+ /**
12
+ * Google Ads' click ids, from the URL when it carries any of them, else from gtag's cookies.
13
+ * All or nothing rather than one by one: the sender picks gclid over gbraid / wbraid, so a
14
+ * landing URL with a fresh gbraid next to the `_gcl_aw` of an earlier click would otherwise send
15
+ * the earlier click. gbraid is an app-to-web id and never kept in a cookie. A gclid that
16
+ * `gclsrc` says is Search Ads 360's is not a Google Ads click and is left out, as gtag leaves it
17
+ * out of `_gcl_aw`.
18
+ */
19
+ export declare function googleClickIds(tags: TrackTags): {
20
+ gclid: string | undefined;
21
+ gbraid: string | undefined;
22
+ wbraid: string | undefined;
23
+ };
24
+ /**
25
+ * Microsoft Ads' click id, in the 32-hex form of the URL and the cookie. A URL value in any other
26
+ * shape is not one (the API drops it), so it does not hide the cookie's.
27
+ */
28
+ export declare function microsoftMsclkid(tags: TrackTags): string | undefined;
29
+ export declare function redditClickId(tags: TrackTags): string | undefined;
30
+ /** The Reddit pixel's browser id, a cookie only. */
31
+ export declare function redditUuid(tags: TrackTags): string | undefined;
32
+ export declare function linkedinFatId(tags: TrackTags): string | undefined;
33
+ /**
34
+ * OpenAI's `oppref`: the pixel captures it from the landing URL and keeps it in `__oppref` for
35
+ * the later pages, and "the API does not capture `oppref` for you".
36
+ * ref: https://developers.openai.com/ads/measurement-pixel#cookie-expiry
37
+ */
38
+ export declare function openaiOppref(tags: TrackTags): string | undefined;
39
+ //#endregion
40
+ //# sourceMappingURL=click-ids.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"click-ids.d.mts","names":[],"sources":["../../src/server/click-ids.ts"],"mappings":";;;;;;;wBA6BgB,QAAQ,MAAM,WAAW;;wBAczB,QAAQ,MAAM;;;;;;;;;wBAYd,eAAe,MAAM;;;;;;;;;wBAerB,iBAAiB,MAAM;wBAKvB,cAAc,MAAM;;wBAKpB,WAAW,MAAM;wBAIjB,cAAc,MAAM;;;;;;wBASpB,aAAa,MAAM"}
@@ -0,0 +1,85 @@
1
+ import { formatFbc, isGoogleAdsGclid, isMsclkid, parseFbc, parseGcl, parseUetMsclkid } from "../click-id/index.mjs";
2
+ //#region src/server/click-ids.ts
3
+ /**
4
+ * The click id each conversion sender passes on, resolved from an event's tags.
5
+ *
6
+ * The tags keep a URL parameter and the cookie that persists it apart (`gclid` / `_gcl_aw`, see
7
+ * `AdvertisingInfo`). The URL parameter wins: the two only coexist on the landing page, where the
8
+ * parameter is this very click while the cookie may still hold an earlier one — the pixel that
9
+ * rewrites it can run after this SDK read it. On every later page and visit the URL carries
10
+ * nothing and the cookie is what remains of the click.
11
+ *
12
+ * Clients older than 9.0.0 send `fbc` / `fbp` / `rdt_uuid` for the cookies (and may have filled
13
+ * `gclid`, `wbraid`, `msclkid`, `rdt_cid` and `li_fat_id` from them), so those are still read
14
+ * as a fallback. Transitional: drop them here and in `tagsSchema` and `AdvertisingInfo` once no
15
+ * client sends them.
16
+ */
17
+ /**
18
+ * Meta's `fbc`: built from this page's `fbclid`, else the `_fbc` cookie. One exception on the
19
+ * landing page: a cookie opened by that same `fbclid` is sent as it is, for its creationTime —
20
+ * `resolveClickIdCookies` sets it on the landing document, before any event of the page.
21
+ */
22
+ function metaFbc(tags, eventTimeMs) {
23
+ const cookie = tags._fbc ?? tags.fbc;
24
+ if (tags.fbclid) {
25
+ if (parseFbc(cookie)?.fbclid === tags.fbclid) return cookie;
26
+ return formatFbc(tags.fbclid, eventTimeMs);
27
+ }
28
+ return cookie;
29
+ }
30
+ /** Meta's `fbp`: the pixel's browser id, a cookie only. */
31
+ function metaFbp(tags) {
32
+ return tags._fbp ?? tags.fbp;
33
+ }
34
+ /**
35
+ * Google Ads' click ids, from the URL when it carries any of them, else from gtag's cookies.
36
+ * All or nothing rather than one by one: the sender picks gclid over gbraid / wbraid, so a
37
+ * landing URL with a fresh gbraid next to the `_gcl_aw` of an earlier click would otherwise send
38
+ * the earlier click. gbraid is an app-to-web id and never kept in a cookie. A gclid that
39
+ * `gclsrc` says is Search Ads 360's is not a Google Ads click and is left out, as gtag leaves it
40
+ * out of `_gcl_aw`.
41
+ */
42
+ function googleClickIds(tags) {
43
+ const gclid = isGoogleAdsGclid(tags.gclsrc) ? tags.gclid : void 0;
44
+ const { gbraid, wbraid } = tags;
45
+ if (gclid || gbraid || wbraid) return {
46
+ gclid,
47
+ gbraid,
48
+ wbraid
49
+ };
50
+ return {
51
+ gclid: parseGcl(tags._gcl_aw)?.clickId,
52
+ gbraid: void 0,
53
+ wbraid: parseGcl(tags._gcl_gb)?.clickId
54
+ };
55
+ }
56
+ /**
57
+ * Microsoft Ads' click id, in the 32-hex form of the URL and the cookie. A URL value in any other
58
+ * shape is not one (the API drops it), so it does not hide the cookie's.
59
+ */
60
+ function microsoftMsclkid(tags) {
61
+ const url = tags.msclkid?.trim();
62
+ return isMsclkid(url) ? url : parseUetMsclkid(tags._uetmsclkid);
63
+ }
64
+ function redditClickId(tags) {
65
+ return tags.rdt_cid ?? tags._rdt_cid;
66
+ }
67
+ /** The Reddit pixel's browser id, a cookie only. */
68
+ function redditUuid(tags) {
69
+ return tags._rdt_uuid ?? tags.rdt_uuid;
70
+ }
71
+ function linkedinFatId(tags) {
72
+ return tags.li_fat_id ?? tags._li_fat_id;
73
+ }
74
+ /**
75
+ * OpenAI's `oppref`: the pixel captures it from the landing URL and keeps it in `__oppref` for
76
+ * the later pages, and "the API does not capture `oppref` for you".
77
+ * ref: https://developers.openai.com/ads/measurement-pixel#cookie-expiry
78
+ */
79
+ function openaiOppref(tags) {
80
+ return tags.oppref ?? tags.__oppref;
81
+ }
82
+ //#endregion
83
+ export { googleClickIds, linkedinFatId, metaFbc, metaFbp, microsoftMsclkid, openaiOppref, redditClickId, redditUuid };
84
+
85
+ //# sourceMappingURL=click-ids.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"click-ids.mjs","names":[],"sources":["../../src/server/click-ids.ts"],"sourcesContent":["/**\n * The click id each conversion sender passes on, resolved from an event's tags.\n *\n * The tags keep a URL parameter and the cookie that persists it apart (`gclid` / `_gcl_aw`, see\n * `AdvertisingInfo`). The URL parameter wins: the two only coexist on the landing page, where the\n * parameter is this very click while the cookie may still hold an earlier one — the pixel that\n * rewrites it can run after this SDK read it. On every later page and visit the URL carries\n * nothing and the cookie is what remains of the click.\n *\n * Clients older than 9.0.0 send `fbc` / `fbp` / `rdt_uuid` for the cookies (and may have filled\n * `gclid`, `wbraid`, `msclkid`, `rdt_cid` and `li_fat_id` from them), so those are still read\n * as a fallback. Transitional: drop them here and in `tagsSchema` and `AdvertisingInfo` once no\n * client sends them.\n */\nimport {\n formatFbc,\n isGoogleAdsGclid,\n isMsclkid,\n parseFbc,\n parseGcl,\n parseUetMsclkid,\n} from '../click-id/index';\nimport type { TrackTags } from '../track/types';\n\n/**\n * Meta's `fbc`: built from this page's `fbclid`, else the `_fbc` cookie. One exception on the\n * landing page: a cookie opened by that same `fbclid` is sent as it is, for its creationTime —\n * `resolveClickIdCookies` sets it on the landing document, before any event of the page.\n */\nexport function metaFbc(tags: TrackTags, eventTimeMs: number): string | undefined {\n const cookie = tags._fbc ?? tags.fbc;\n if (tags.fbclid) {\n if (parseFbc(cookie)?.fbclid === tags.fbclid) return cookie;\n // ref: https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/fbp-and-fbc#2--format-clickid\n // Without the cookie, creationTime is \"the timestamp when you first observed or received\n // this fbclid value\" — the event's own time is the closest thing the server has to that,\n // and it does not move when a queued or retried batch finally goes out.\n return formatFbc(tags.fbclid, eventTimeMs);\n }\n return cookie;\n}\n\n/** Meta's `fbp`: the pixel's browser id, a cookie only. */\nexport function metaFbp(tags: TrackTags): string | undefined {\n return tags._fbp ?? tags.fbp;\n}\n\n/**\n * Google Ads' click ids, from the URL when it carries any of them, else from gtag's cookies.\n * All or nothing rather than one by one: the sender picks gclid over gbraid / wbraid, so a\n * landing URL with a fresh gbraid next to the `_gcl_aw` of an earlier click would otherwise send\n * the earlier click. gbraid is an app-to-web id and never kept in a cookie. A gclid that\n * `gclsrc` says is Search Ads 360's is not a Google Ads click and is left out, as gtag leaves it\n * out of `_gcl_aw`.\n */\nexport function googleClickIds(tags: TrackTags) {\n const gclid = isGoogleAdsGclid(tags.gclsrc) ? tags.gclid : undefined;\n const { gbraid, wbraid } = tags;\n if (gclid || gbraid || wbraid) return { gclid, gbraid, wbraid };\n return {\n gclid: parseGcl(tags._gcl_aw)?.clickId,\n gbraid: undefined,\n wbraid: parseGcl(tags._gcl_gb)?.clickId,\n };\n}\n\n/**\n * Microsoft Ads' click id, in the 32-hex form of the URL and the cookie. A URL value in any other\n * shape is not one (the API drops it), so it does not hide the cookie's.\n */\nexport function microsoftMsclkid(tags: TrackTags): string | undefined {\n const url = tags.msclkid?.trim();\n return isMsclkid(url) ? url : parseUetMsclkid(tags._uetmsclkid);\n}\n\nexport function redditClickId(tags: TrackTags): string | undefined {\n return tags.rdt_cid ?? tags._rdt_cid;\n}\n\n/** The Reddit pixel's browser id, a cookie only. */\nexport function redditUuid(tags: TrackTags): string | undefined {\n return tags._rdt_uuid ?? tags.rdt_uuid;\n}\n\nexport function linkedinFatId(tags: TrackTags): string | undefined {\n return tags.li_fat_id ?? tags._li_fat_id;\n}\n\n/**\n * OpenAI's `oppref`: the pixel captures it from the landing URL and keeps it in `__oppref` for\n * the later pages, and \"the API does not capture `oppref` for you\".\n * ref: https://developers.openai.com/ads/measurement-pixel#cookie-expiry\n */\nexport function openaiOppref(tags: TrackTags): string | undefined {\n return tags.oppref ?? tags.__oppref;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,QAAQ,MAAiB,aAAyC;CAChF,MAAM,SAAS,KAAK,QAAQ,KAAK;CACjC,IAAI,KAAK,QAAQ;EACf,IAAI,SAAS,MAAM,CAAC,EAAE,WAAW,KAAK,QAAQ,OAAO;EAKrD,OAAO,UAAU,KAAK,QAAQ,WAAW;CAC3C;CACA,OAAO;AACT;;AAGA,SAAgB,QAAQ,MAAqC;CAC3D,OAAO,KAAK,QAAQ,KAAK;AAC3B;;;;;;;;;AAUA,SAAgB,eAAe,MAAiB;CAC9C,MAAM,QAAQ,iBAAiB,KAAK,MAAM,IAAI,KAAK,QAAQ,KAAA;CAC3D,MAAM,EAAE,QAAQ,WAAW;CAC3B,IAAI,SAAS,UAAU,QAAQ,OAAO;EAAE;EAAO;EAAQ;CAAO;CAC9D,OAAO;EACL,OAAO,SAAS,KAAK,OAAO,CAAC,EAAE;EAC/B,QAAQ,KAAA;EACR,QAAQ,SAAS,KAAK,OAAO,CAAC,EAAE;CAClC;AACF;;;;;AAMA,SAAgB,iBAAiB,MAAqC;CACpE,MAAM,MAAM,KAAK,SAAS,KAAK;CAC/B,OAAO,UAAU,GAAG,IAAI,MAAM,gBAAgB,KAAK,WAAW;AAChE;AAEA,SAAgB,cAAc,MAAqC;CACjE,OAAO,KAAK,WAAW,KAAK;AAC9B;;AAGA,SAAgB,WAAW,MAAqC;CAC9D,OAAO,KAAK,aAAa,KAAK;AAChC;AAEA,SAAgB,cAAc,MAAqC;CACjE,OAAO,KAAK,aAAa,KAAK;AAChC;;;;;;AAOA,SAAgB,aAAa,MAAqC;CAChE,OAAO,KAAK,UAAU,KAAK;AAC7B"}
@@ -1,6 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_third_parties_ignored_events = require("../third-parties/ignored-events.cjs");
3
3
  const require_server_action_source = require("./action-source.cjs");
4
+ const require_server_click_ids = require("./click-ids.cjs");
4
5
  let _shware_utils = require("@shware/utils");
5
6
  let node_crypto = require("node:crypto");
6
7
  //#region src/server/google-data-manager.ts
@@ -79,7 +80,7 @@ function getDataManagerEvent(event, config, data = {}) {
79
80
  conversionValue: typeof event.properties?.value === "number" ? event.properties.value : 0,
80
81
  currency: normalizeCurrency(event.properties?.currency)
81
82
  };
82
- const { gclid, gbraid, wbraid } = event.tags;
83
+ const { gclid, gbraid, wbraid } = require_server_click_ids.googleClickIds(event.tags);
83
84
  if (gclid) dmEvent.adIdentifiers = { gclid };
84
85
  else if (gbraid) dmEvent.adIdentifiers = { gbraid };
85
86
  else if (wbraid) dmEvent.adIdentifiers = { wbraid };
@@ -1 +1 @@
1
- {"version":3,"file":"google-data-manager.cjs","names":["createHash","resolveActionSource","IGNORED_EVENTS","fetch"],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = event.tags;\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"google-data-manager.cjs","names":["createHash","resolveActionSource","googleClickIds","IGNORED_EVENTS","fetch"],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { googleClickIds } from './click-ids';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = googleClickIds(event.tags);\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,MAAM,WAAW;;;;;;AAsCjB,SAAgB,eAAe,OAAuB;CACpD,MAAM,QAAQ,MAAM,KAAK,CAAC,CAAC,YAAY;CACvC,MAAM,KAAK,MAAM,YAAY,GAAG;CAChC,IAAI,OAAO,IAAI,OAAO;CACtB,IAAI,WAAW,MAAM,MAAM,GAAG,EAAE;CAChC,MAAM,SAAS,MAAM,MAAM,KAAK,CAAC;CACjC,IAAI,WAAW,eAAe,WAAW,kBACvC,WAAW,SAAS,QAAQ,OAAO,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,MAAM;CAE7D,OAAO,GAAG,SAAS,GAAG;AACxB;AAEA,MAAM,UAAU,WAAA,GAAkBA,YAAAA,WAAAA,CAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;AAEjF,SAAS,kBAAkB,OAAwB;CACjD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,MAAM,WAAW,MAAM,KAAK,CAAC,CAAC,YAAY;CAC1C,OAAO,aAAa,KAAK,QAAQ,IAAI,WAAW;AAClD;;AAGA,MAAM,uBAAuB;;AAG7B,SAAS,mBAAmB,MAAkD;CAC5E,MAAM,cAAwC,CAAC;CAC/C,MAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK,QAAQ,KAAK,QAAQ,CAAC,KAAK,KAAK,IAAI,CAAC;CACrF,KAAK,MAAM,SAAS,QAClB,YAAY,KAAK,EAAE,cAAc,OAAO,eAAe,KAAK,CAAC,EAAE,CAAC;CAElE,MAAM,SAAS,MAAM,QAAQ,KAAK,YAAY,IAC1C,KAAK,eACL,KAAK,eACH,CAAC,KAAK,YAAY,IAClB,CAAC;CACP,KAAK,MAAM,SAAS,QAElB,YAAY,KAAK,EAAE,aAAa,OAAO,MAAM,KAAK,CAAC,EAAE,CAAC;CAExD,OAAO,YAAY,MAAM,GAAG,oBAAoB;AAClD;;;;;;;;AAsBA,SAAgB,oBAEd,OACA,QACA,OAAyB,CAAC,GACI;CAC9B,MAAM,SAAS,OAAO,MAAM;CAC5B,IAAI,CAAC,QAAQ,OAAO,KAAA;CAEpB,MAAM,SAASC,6BAAAA,oBAAoB,MAAM,QAAQ;CACjD,MAAM,UAA4B;EAChC,uBAAuB,CAAC,OAAO,MAAM,CAAC;EAOtC,eACE,OAAO,MAAM,YAAY,mBAAmB,WACxC,MAAM,WAAW,iBACjB,MAAM;EACZ,gBAAgB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,YAAY;EACvD,aAAa,WAAW,QAAQ,QAAQ,WAAW,QAAQ,QAAQ;EAInE,iBAAiB,OAAO,MAAM,YAAY,UAAU,WAAW,MAAM,WAAW,QAAQ;EACxF,UAAU,kBAAkB,MAAM,YAAY,QAAQ;CACxD;CAEA,MAAM,EAAE,OAAO,QAAQ,WAAWC,yBAAAA,eAAe,MAAM,IAAI;CAC3D,IAAI,OAAO,QAAQ,gBAAgB,EAAE,MAAM;MACtC,IAAI,QAAQ,QAAQ,gBAAgB,EAAE,OAAO;MAC7C,IAAI,QAAQ,QAAQ,gBAAgB,EAAE,OAAO;CAElD,MAAM,kBAAkB,mBAAmB,IAAI;CAC/C,IAAI,gBAAgB,SAAS,GAAG,QAAQ,WAAW,EAAE,gBAAgB;CAGrE,IAAI,CAAC,QAAQ,iBAAiB,CAAC,QAAQ,UAAU,OAAO,KAAA;CAExD,OAAO;AACT;;AAOA,MAAM,yBAAyB;;;;;;;;AAS/B,eAAsB,WACpB,MACA,QAEA,QACA,OAAyB,CAAC,GAC1B,UAAuC,CAAC,GACE;CAC1C,MAAM,WAAW,OACd,QAAQ,UAAU,CAACC,qCAAAA,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CACvD,KAAK,UAAU,oBAAoB,OAAO,QAAQ,IAAI,CAAC,CAAC,CACxD,QAAQ,YAAyC,YAAY,KAAA,CAAS;CACzE,IAAI,SAAS,WAAW,GAAG,OAAO,KAAA;CAElC,MAAM,mBAAmB;EACvB,aAAa;EACb,WAAW,KAAK,mBAAmB,QAAQ,MAAM,EAAE;CACrD;CAGA,MAAM,eAAe,KAAK,iBACtB;EAAE,aAAa;EAAc,WAAW,KAAK,eAAe,QAAQ,MAAM,EAAE;CAAE,IAC9E,KAAA;CAEJ,MAAM,eADU,MAAM,KAAK,IAAI,IAAI,SAAS,SAAS,YAAY,QAAQ,qBAAqB,CAAC,CACpE,CAAC,CAAC,KAAK,WAAW;EAC3C,MAAM,cAAuC;GAC3C,WAAW;GACX;GACA,sBAAsB;EACxB;EACA,IAAI,cAAc,YAAY,eAAe;EAC7C,OAAO;CACT,CAAC;CAED,IAAI;CACJ,KAAK,IAAI,SAAS,GAAG,SAAS,SAAS,QAAQ,UAAU,wBAAwB;EAC/E,MAAM,QAAQ,SAAS,MAAM,QAAQ,SAAS,sBAAsB;EACpE,MAAM,OAAgC;GACpC;GACA,QAAQ;GACR,cAAc,QAAQ,gBAAgB;EACxC;EACA,IAAI,MAAM,MAAM,YAAY,QAAQ,QAAQ,GAC1C,KAAK,WAAW;EAElB,IAAI,QAAQ,SACV,KAAK,UAAU,QAAQ;EAGzB,IAAI;GACF,MAAM,WAAW,OAAA,GAAMC,cAAAA,MAAAA,CAAM,UAAU;IACrC,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,QAAQ;KACR,eAAe,UAAU,KAAK;IAChC;IACA,MAAM,KAAK,UAAU,IAAI;GAC3B,CAAC;GACD,IAAI,SAAS,IAAI;IACf,kBAAmB,MAAM,SAAS,KAAK;IACvC;GACF;GACA,MAAM,EAAE,WAAW;GACnB,MAAM,UAAU,MAAM,SAAS,KAAK;GACpC,QAAQ,MAAM,kDAAkD,OAAO,UAAU,SAAS;EAC5F,SAAS,OAAO;GACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GACrE,QAAQ,MAAM,yDAAyD,SAAS;EAClF;CACF;CACA,OAAO;AACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"google-data-manager.d.cts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAqCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;iBAGe;;EAEf;EACA,UAAU;;;;;;;wBAQI,eAAe;;iBA2Cd;EACf;EACA;;EAEA;EACA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;wBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,mBACL;iBAwCc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
1
+ {"version":3,"file":"google-data-manager.d.cts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAsCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;iBAGe;;EAEf;EACA,UAAU;;;;;;;wBAQI,eAAe;;iBA2Cd;EACf;EACA;;EAEA;EACA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;wBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,mBACL;iBAwCc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
@@ -1 +1 @@
1
- {"version":3,"file":"google-data-manager.d.mts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAqCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;iBAGe;;EAEf;EACA,UAAU;;;;;;;wBAQI,eAAe;;iBA2Cd;EACf;EACA;;EAEA;EACA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;wBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,mBACL;iBAwCc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
1
+ {"version":3,"file":"google-data-manager.d.mts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAsCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;iBAGe;;EAEf;EACA,UAAU;;;;;;;wBAQI,eAAe;;iBA2Cd;EACf;EACA;;EAEA;EACA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;wBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,mBACL;iBAwCc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
@@ -1,5 +1,6 @@
1
1
  import { IGNORED_EVENTS } from "../third-parties/ignored-events.mjs";
2
2
  import { resolveActionSource } from "./action-source.mjs";
3
+ import { googleClickIds } from "./click-ids.mjs";
3
4
  import { fetch } from "@shware/utils";
4
5
  import { createHash } from "node:crypto";
5
6
  //#region src/server/google-data-manager.ts
@@ -78,7 +79,7 @@ function getDataManagerEvent(event, config, data = {}) {
78
79
  conversionValue: typeof event.properties?.value === "number" ? event.properties.value : 0,
79
80
  currency: normalizeCurrency(event.properties?.currency)
80
81
  };
81
- const { gclid, gbraid, wbraid } = event.tags;
82
+ const { gclid, gbraid, wbraid } = googleClickIds(event.tags);
82
83
  if (gclid) dmEvent.adIdentifiers = { gclid };
83
84
  else if (gbraid) dmEvent.adIdentifiers = { gbraid };
84
85
  else if (wbraid) dmEvent.adIdentifiers = { wbraid };
@@ -1 +1 @@
1
- {"version":3,"file":"google-data-manager.mjs","names":[],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = event.tags;\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"google-data-manager.mjs","names":[],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { googleClickIds } from './click-ids';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = googleClickIds(event.tags);\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_third_parties_ignored_events = require("../third-parties/ignored-events.cjs");
3
3
  const require_utils_field = require("../utils/field.cjs");
4
+ const require_server_click_ids = require("./click-ids.cjs");
4
5
  let _shware_utils = require("@shware/utils");
5
6
  let node_crypto = require("node:crypto");
6
7
  //#region src/server/linkedin-conversions-api.ts
@@ -47,6 +48,13 @@ async function sendEvents(accessToken, config, events, data = {}) {
47
48
  idValue: hashEmail(email)
48
49
  });
49
50
  }
51
+ const fatIds = (tags) => {
52
+ const id = require_server_click_ids.linkedinFatId(tags);
53
+ return id ? [{
54
+ idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
55
+ idValue: id
56
+ }] : [];
57
+ };
50
58
  const dto = { elements: events.filter((event) => eventNames.includes(event.name) && !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).map((event) => ({
51
59
  eventId: event.id,
52
60
  conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,
@@ -56,10 +64,7 @@ async function sendEvents(accessToken, config, events, data = {}) {
56
64
  amount: event.properties?.value?.toString() ?? "0"
57
65
  },
58
66
  user: {
59
- userIds: event.tags.li_fat_id ? [{
60
- idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
61
- idValue: event.tags.li_fat_id
62
- }, ...userIds] : userIds,
67
+ userIds: [...fatIds(event.tags), ...userIds],
63
68
  userInfo,
64
69
  externalIds
65
70
  }
@@ -1 +1 @@
1
- {"version":3,"file":"linkedin-conversions-api.cjs","names":["createHash","getFirst","IGNORED_EVENTS","fetch"],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: event.tags.li_fat_id\n ? [\n {\n idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID',\n idValue: event.tags.li_fat_id,\n },\n ...userIds,\n ]\n : userIds,\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAiFA,MAAM,UAAU,WAAA,GAAkBA,YAAAA,WAAAA,CAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;AAIvF,eAAsB,WACpB,aACA,QAEA,QACA,OAAyB,CAAC,GAC1B;CACA,MAAM,aAAa,OAAO,KAAK,MAAM;CACrC,MAAM,UAAUC,oBAAAA,SAAS,KAAK,OAAO;CACrC,MAAM,UAAqD,CAAC;CAC5D,MAAM,cAAiD,KAAK,UAAU,CAAC,KAAK,OAAO,IAAI,KAAA;CACvF,MAAM,WACJ,SAAS,cAAc,QAAQ,YAC3B;EACE,iBAAiB,SAAS,QAAQ,UAAU;EAC5C,gBAAgB,SAAS,QAAQ,SAAS;EAC1C,aAAa,QAAQ;CACvB,IACA,KAAA;CAEN,IAAI,KAAK,OAAO;EACd,MAAM,QAAQA,oBAAAA,SAAS,KAAK,KAAK;EACjC,IAAI,OAAO,QAAQ,KAAK;GAAE,QAAQ;GAAgB,SAAS,UAAU,KAAK;EAAE,CAAC;CAC/E;CAEA,MAAM,MAAuC,EAC3C,UAAU,OACP,QAAQ,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,CAACC,qCAAAA,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CAC1F,KAAK,WAAmC;EACvC,SAAS,MAAM;EACf,YAAY,gCAAgC,OAAO,MAAM;EACzD,sBAAsB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;EACzD,iBAAiB;GACf,cAAc,MAAM,YAAY,UAAU,YAAY,KAAK;GAC3D,QAAQ,MAAM,YAAY,OAAO,SAAS,KAAK;EACjD;EACA,MAAM;GACJ,SAAS,MAAM,KAAK,YAChB,CACE;IACE,QAAQ;IACR,SAAS,MAAM,KAAK;GACtB,GACA,GAAG,OACL,IACA;GACJ;GACA;EACF;CACF,EAAE,CAAC,CAIF,QACE,EAAE,WACD,KAAK,QAAQ,SAAS,KAAK,CAAC,CAAC,KAAK,YAAY,CAAC,CAAC,KAAK,eAAe,CAAC,CAAC,KAAK,IAC/E,EACJ;CAEA,IAAI,IAAI,SAAS,WAAW,GAAG;CAC/B,IAAI;EACF,MAAM,WAAW,OAAA,GAAMC,cAAAA,MAAAA,CAAM,kDAAkD;GAC7E,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;IAChB,oBAAoB;IACpB,6BAA6B;IAC7B,mBAAmB;GACrB;GACA,MAAM,KAAK,UAAU,GAAG;EAC1B,CAAC;EAED,IAAI,SAAS,IAAI;EACjB,MAAM,EAAE,WAAW;EACnB,MAAM,UAAU,MAAM,SAAS,KAAK;EACpC,QAAQ,MAAM,+CAA+C,OAAO,UAAU,SAAS;CACzF,SAAS,OAAO;EACd,QAAQ,MAAM,sDAAsD,KAAK;CAC3E;AACF"}
1
+ {"version":3,"file":"linkedin-conversions-api.cjs","names":["createHash","getFirst","linkedinFatId","IGNORED_EVENTS","fetch"],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\nimport { linkedinFatId } from './click-ids';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const fatIds = (tags: TrackTags): { idType: UserIdType; idValue: string }[] => {\n const id = linkedinFatId(tags);\n return id ? [{ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: id }] : [];\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: [...fatIds(event.tags), ...userIds],\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAkFA,MAAM,UAAU,WAAA,GAAkBA,YAAAA,WAAAA,CAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;AAIvF,eAAsB,WACpB,aACA,QAEA,QACA,OAAyB,CAAC,GAC1B;CACA,MAAM,aAAa,OAAO,KAAK,MAAM;CACrC,MAAM,UAAUC,oBAAAA,SAAS,KAAK,OAAO;CACrC,MAAM,UAAqD,CAAC;CAC5D,MAAM,cAAiD,KAAK,UAAU,CAAC,KAAK,OAAO,IAAI,KAAA;CACvF,MAAM,WACJ,SAAS,cAAc,QAAQ,YAC3B;EACE,iBAAiB,SAAS,QAAQ,UAAU;EAC5C,gBAAgB,SAAS,QAAQ,SAAS;EAC1C,aAAa,QAAQ;CACvB,IACA,KAAA;CAEN,IAAI,KAAK,OAAO;EACd,MAAM,QAAQA,oBAAAA,SAAS,KAAK,KAAK;EACjC,IAAI,OAAO,QAAQ,KAAK;GAAE,QAAQ;GAAgB,SAAS,UAAU,KAAK;EAAE,CAAC;CAC/E;CAEA,MAAM,UAAU,SAA+D;EAC7E,MAAM,KAAKC,yBAAAA,cAAc,IAAI;EAC7B,OAAO,KAAK,CAAC;GAAE,QAAQ;GAA0C,SAAS;EAAG,CAAC,IAAI,CAAC;CACrF;CAEA,MAAM,MAAuC,EAC3C,UAAU,OACP,QAAQ,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,CAACC,qCAAAA,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CAC1F,KAAK,WAAmC;EACvC,SAAS,MAAM;EACf,YAAY,gCAAgC,OAAO,MAAM;EACzD,sBAAsB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;EACzD,iBAAiB;GACf,cAAc,MAAM,YAAY,UAAU,YAAY,KAAK;GAC3D,QAAQ,MAAM,YAAY,OAAO,SAAS,KAAK;EACjD;EACA,MAAM;GACJ,SAAS,CAAC,GAAG,OAAO,MAAM,IAAI,GAAG,GAAG,OAAO;GAC3C;GACA;EACF;CACF,EAAE,CAAC,CAIF,QACE,EAAE,WACD,KAAK,QAAQ,SAAS,KAAK,CAAC,CAAC,KAAK,YAAY,CAAC,CAAC,KAAK,eAAe,CAAC,CAAC,KAAK,IAC/E,EACJ;CAEA,IAAI,IAAI,SAAS,WAAW,GAAG;CAC/B,IAAI;EACF,MAAM,WAAW,OAAA,GAAMC,cAAAA,MAAAA,CAAM,kDAAkD;GAC7E,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;IAChB,oBAAoB;IACpB,6BAA6B;IAC7B,mBAAmB;GACrB;GACA,MAAM,KAAK,UAAU,GAAG;EAC1B,CAAC;EAED,IAAI,SAAS,IAAI;EACjB,MAAM,EAAE,WAAW;EACnB,MAAM,UAAU,MAAM,SAAS,KAAK;EACpC,QAAQ,MAAM,+CAA+C,OAAO,UAAU,SAAS;CACzF,SAAS,OAAO;EACd,QAAQ,MAAM,sDAAsD,KAAK;CAC3E;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"linkedin-conversions-api.d.cts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAcK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;wBAExB,WACpB,qBACA,QAAQ,0BAER,QAAQ,mBACR,OAAM,mBAAqB"}
1
+ {"version":3,"file":"linkedin-conversions-api.d.cts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAeK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;wBAExB,WACpB,qBACA,QAAQ,0BAER,QAAQ,mBACR,OAAM,mBAAqB"}
@@ -1 +1 @@
1
- {"version":3,"file":"linkedin-conversions-api.d.mts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAcK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;wBAExB,WACpB,qBACA,QAAQ,0BAER,QAAQ,mBACR,OAAM,mBAAqB"}
1
+ {"version":3,"file":"linkedin-conversions-api.d.mts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAeK;iBAQY;;;;;;;;;EASf;;;;;EAMA;;EAGA;EACA;IAAmB;IAAsB;;EACzC;IACE;MAAW,QAAQ;MAAY;;;;;;;;IAO/B;MACE;MACA;MACA;MACA;MACA;;;;;;IAOF;;;;;IAMA;;;iBAIa;EACf,UAAU;;YA4BA,2BAA2B,OAAO;wBAExB,WACpB,qBACA,QAAQ,0BAER,QAAQ,mBACR,OAAM,mBAAqB"}
@@ -1,5 +1,6 @@
1
1
  import { IGNORED_EVENTS } from "../third-parties/ignored-events.mjs";
2
2
  import { getFirst } from "../utils/field.mjs";
3
+ import { linkedinFatId } from "./click-ids.mjs";
3
4
  import { fetch } from "@shware/utils";
4
5
  import { createHash } from "node:crypto";
5
6
  //#region src/server/linkedin-conversions-api.ts
@@ -46,6 +47,13 @@ async function sendEvents(accessToken, config, events, data = {}) {
46
47
  idValue: hashEmail(email)
47
48
  });
48
49
  }
50
+ const fatIds = (tags) => {
51
+ const id = linkedinFatId(tags);
52
+ return id ? [{
53
+ idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
54
+ idValue: id
55
+ }] : [];
56
+ };
49
57
  const dto = { elements: events.filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name)).map((event) => ({
50
58
  eventId: event.id,
51
59
  conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,
@@ -55,10 +63,7 @@ async function sendEvents(accessToken, config, events, data = {}) {
55
63
  amount: event.properties?.value?.toString() ?? "0"
56
64
  },
57
65
  user: {
58
- userIds: event.tags.li_fat_id ? [{
59
- idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
60
- idValue: event.tags.li_fat_id
61
- }, ...userIds] : userIds,
66
+ userIds: [...fatIds(event.tags), ...userIds],
62
67
  userInfo,
63
68
  externalIds
64
69
  }
@@ -1 +1 @@
1
- {"version":3,"file":"linkedin-conversions-api.mjs","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: event.tags.li_fat_id\n ? [\n {\n idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID',\n idValue: event.tags.li_fat_id,\n },\n ...userIds,\n ]\n : userIds,\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;AAiFA,MAAM,UAAU,UAAkB,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;AAIvF,eAAsB,WACpB,aACA,QAEA,QACA,OAAyB,CAAC,GAC1B;CACA,MAAM,aAAa,OAAO,KAAK,MAAM;CACrC,MAAM,UAAU,SAAS,KAAK,OAAO;CACrC,MAAM,UAAqD,CAAC;CAC5D,MAAM,cAAiD,KAAK,UAAU,CAAC,KAAK,OAAO,IAAI,KAAA;CACvF,MAAM,WACJ,SAAS,cAAc,QAAQ,YAC3B;EACE,iBAAiB,SAAS,QAAQ,UAAU;EAC5C,gBAAgB,SAAS,QAAQ,SAAS;EAC1C,aAAa,QAAQ;CACvB,IACA,KAAA;CAEN,IAAI,KAAK,OAAO;EACd,MAAM,QAAQ,SAAS,KAAK,KAAK;EACjC,IAAI,OAAO,QAAQ,KAAK;GAAE,QAAQ;GAAgB,SAAS,UAAU,KAAK;EAAE,CAAC;CAC/E;CAEA,MAAM,MAAuC,EAC3C,UAAU,OACP,QAAQ,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CAC1F,KAAK,WAAmC;EACvC,SAAS,MAAM;EACf,YAAY,gCAAgC,OAAO,MAAM;EACzD,sBAAsB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;EACzD,iBAAiB;GACf,cAAc,MAAM,YAAY,UAAU,YAAY,KAAK;GAC3D,QAAQ,MAAM,YAAY,OAAO,SAAS,KAAK;EACjD;EACA,MAAM;GACJ,SAAS,MAAM,KAAK,YAChB,CACE;IACE,QAAQ;IACR,SAAS,MAAM,KAAK;GACtB,GACA,GAAG,OACL,IACA;GACJ;GACA;EACF;CACF,EAAE,CAAC,CAIF,QACE,EAAE,WACD,KAAK,QAAQ,SAAS,KAAK,CAAC,CAAC,KAAK,YAAY,CAAC,CAAC,KAAK,eAAe,CAAC,CAAC,KAAK,IAC/E,EACJ;CAEA,IAAI,IAAI,SAAS,WAAW,GAAG;CAC/B,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,kDAAkD;GAC7E,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;IAChB,oBAAoB;IACpB,6BAA6B;IAC7B,mBAAmB;GACrB;GACA,MAAM,KAAK,UAAU,GAAG;EAC1B,CAAC;EAED,IAAI,SAAS,IAAI;EACjB,MAAM,EAAE,WAAW;EACnB,MAAM,UAAU,MAAM,SAAS,KAAK;EACpC,QAAQ,MAAM,+CAA+C,OAAO,UAAU,SAAS;CACzF,SAAS,OAAO;EACd,QAAQ,MAAM,sDAAsD,KAAK;CAC3E;AACF"}
1
+ {"version":3,"file":"linkedin-conversions-api.mjs","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"sourcesContent":["/**\n * Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder\n * https://learn.microsoft.com/en-us/linkedin/marketing/conversions/conversions-overview?view=li-lms-2026-09\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { getFirst } from '../utils/field';\nimport { linkedinFatId } from './click-ids';\n\n/**\n * The identifier types LinkedIn matches on, as of version 202609. `ORACLE_MOAT_ID` used to be\n * here and is no longer in the schema; the IP and Android ones are.\n */\ntype UserIdType =\n | 'SHA256_EMAIL'\n | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID'\n | 'ACXIOM_ID'\n | 'PLAINTEXT_IP_ADDRESS'\n | 'SHA256_IP_ADDRESS'\n | 'GOOGLE_AID';\n\nexport interface CreateLinkedinEventDTO {\n /**\n * For any conversion that you want to send through multiple methods, such as Insight Tag and\n * Conversions API, you must create a conversion rule for each data source (browser and server).\n * Then, you can implement a logic to pick up the eventId from the browser and send it with the\n * corresponding event from your server. If we receive an Insight Tag event and a Conversions API\n * event from the same account with the same eventId, we discard the Conversions API event and\n * count only the Insight Tag event in campaign reporting.\n */\n eventId?: string;\n\n /**\n * Replace <id> with the conversion ID extracted when creating the conversion rule\n * (e.g. urn:lla:llaPartnerConversion:<id>).\n */\n conversion: `urn:lla:llaPartnerConversion:${number}`;\n\n /** Epoch timestamp in milliseconds at which the conversion event happened. */\n conversionHappenedAt: number;\n conversionValue: { currencyCode: string; amount: string };\n user: {\n userIds: { idType: UserIdType; idValue: string }[];\n /**\n * Probabilistic matching fields. `hashedFirstName` / `hashedLastName` arrived in 202609 and\n * are what we send; the plaintext `firstName` / `lastName` they replace are still accepted by\n * the API but are not listed here, because sending a name in the clear is not something this\n * client should make easy to reach for.\n */\n userInfo?: {\n hashedFirstName?: string;\n hashedLastName?: string;\n companyName?: string;\n countryCode?: string;\n title?: string;\n };\n\n /**\n * The maximum supported size of the list is 1 at the moment. If the list contains multiple\n * values, only the first value will be used.\n */\n externalIds?: [string, ...string[]];\n\n /**\n * This is generated when users submit the Linkedin Lead-gen form\n * (e.g. urn:li:leadGenFormResponse:<id>).\n */\n lead?: `urn:li:leadGenFormResponse:${string}`;\n };\n}\n\nexport interface CreateMultipleLinkedinEventsDTO {\n elements: CreateLinkedinEventDTO[];\n}\n\n/**\n * The schema prescribes an exact normalization per field before hashing, and getting it wrong is\n * silent: LinkedIn accepts any 64-char hex digest and simply matches nobody. So these helpers are\n * the difference between attribution and nothing, not between 200 and 400.\n */\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * An email is lower-cased and stripped of whitespace before hashing. Punctuation is left alone —\n * `@` and `.` are the address.\n */\nconst hashEmail = (email: string) => sha256(email.toLowerCase().replace(/\\s/g, ''));\n\n/**\n * A name is lower-cased and stripped of whitespace *and punctuation* before hashing, so that\n * `O'Brien`, `o brien` and `obrien` all land on the same digest. UTF-8 is what `update` already\n * encodes with.\n *\n * The schema's wording (\"lowercase with no spaces or punctuation\") leaves room to read the\n * apostrophe either way, so this is checked against its own worked example rather than guessed:\n * the `hashedFirstName` / `hashedLastName` digests it prints are sha256('mary') and\n * sha256('obrien') — Mary O'Brien with the apostrophe removed, not escaped or kept.\n */\nconst hashName = (name: string) => sha256(name.toLowerCase().replace(/[\\s\\p{P}]/gu, ''));\n\nexport type LinkedinConversionConfig = Record<Lowercase<string>, number>;\n\nexport async function sendEvents(\n accessToken: string,\n config: LinkedinConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {}\n) {\n const eventNames = Object.keys(config);\n const address = getFirst(data.address);\n const userIds: { idType: UserIdType; idValue: string }[] = [];\n const externalIds: [string, ...string[]] | undefined = data.user_id ? [data.user_id] : undefined;\n const userInfo =\n address?.first_name && address.last_name\n ? {\n hashedFirstName: hashName(address.first_name),\n hashedLastName: hashName(address.last_name),\n countryCode: address.country,\n }\n : undefined;\n\n if (data.email) {\n const email = getFirst(data.email);\n if (email) userIds.push({ idType: 'SHA256_EMAIL', idValue: hashEmail(email) });\n }\n\n const fatIds = (tags: TrackTags): { idType: UserIdType; idValue: string }[] => {\n const id = linkedinFatId(tags);\n return id ? [{ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: id }] : [];\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .map((event): CreateLinkedinEventDTO => ({\n eventId: event.id,\n conversion: `urn:lla:llaPartnerConversion:${config[event.name]}`,\n conversionHappenedAt: new Date(event.created_at).getTime(),\n conversionValue: {\n currencyCode: event.properties?.currency?.toUpperCase() ?? 'USD',\n amount: event.properties?.value?.toString() ?? '0',\n },\n user: {\n userIds: [...fatIds(event.tags), ...userIds],\n userInfo,\n externalIds,\n },\n }))\n // An element carrying no identifier at all fails validation, and LinkedIn fails the whole\n // batch when one element fails — so a single anonymous event would discard every\n // identifiable conversion sent alongside it. Dropped here instead.\n .filter(\n ({ user }) =>\n user.userIds.length > 0 || !!user.userInfo || !!user.externalIds || !!user.lead\n ),\n };\n\n if (dto.elements.length === 0) return;\n try {\n const response = await fetch('https://api.linkedin.com/rest/conversionEvents', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${accessToken}`,\n 'Content-Type': 'application/json',\n 'LinkedIn-Version': '202609',\n 'X-Restli-Protocol-Version': '2.0.0',\n 'X-RestLi-Method': 'BATCH_CREATE',\n },\n body: JSON.stringify(dto),\n });\n\n if (response.ok) return;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send LinkedIn conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n console.error('Failed to send LinkedIn conversion, network error:', error);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;AAkFA,MAAM,UAAU,UAAkB,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;;;;;AAMjF,MAAM,aAAa,UAAkB,OAAO,MAAM,YAAY,CAAC,CAAC,QAAQ,OAAO,EAAE,CAAC;;;;;;;;;;;AAYlF,MAAM,YAAY,SAAiB,OAAO,KAAK,YAAY,CAAC,CAAC,QAAQ,eAAe,EAAE,CAAC;AAIvF,eAAsB,WACpB,aACA,QAEA,QACA,OAAyB,CAAC,GAC1B;CACA,MAAM,aAAa,OAAO,KAAK,MAAM;CACrC,MAAM,UAAU,SAAS,KAAK,OAAO;CACrC,MAAM,UAAqD,CAAC;CAC5D,MAAM,cAAiD,KAAK,UAAU,CAAC,KAAK,OAAO,IAAI,KAAA;CACvF,MAAM,WACJ,SAAS,cAAc,QAAQ,YAC3B;EACE,iBAAiB,SAAS,QAAQ,UAAU;EAC5C,gBAAgB,SAAS,QAAQ,SAAS;EAC1C,aAAa,QAAQ;CACvB,IACA,KAAA;CAEN,IAAI,KAAK,OAAO;EACd,MAAM,QAAQ,SAAS,KAAK,KAAK;EACjC,IAAI,OAAO,QAAQ,KAAK;GAAE,QAAQ;GAAgB,SAAS,UAAU,KAAK;EAAE,CAAC;CAC/E;CAEA,MAAM,UAAU,SAA+D;EAC7E,MAAM,KAAK,cAAc,IAAI;EAC7B,OAAO,KAAK,CAAC;GAAE,QAAQ;GAA0C,SAAS;EAAG,CAAC,IAAI,CAAC;CACrF;CAEA,MAAM,MAAuC,EAC3C,UAAU,OACP,QAAQ,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CAC1F,KAAK,WAAmC;EACvC,SAAS,MAAM;EACf,YAAY,gCAAgC,OAAO,MAAM;EACzD,sBAAsB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,QAAQ;EACzD,iBAAiB;GACf,cAAc,MAAM,YAAY,UAAU,YAAY,KAAK;GAC3D,QAAQ,MAAM,YAAY,OAAO,SAAS,KAAK;EACjD;EACA,MAAM;GACJ,SAAS,CAAC,GAAG,OAAO,MAAM,IAAI,GAAG,GAAG,OAAO;GAC3C;GACA;EACF;CACF,EAAE,CAAC,CAIF,QACE,EAAE,WACD,KAAK,QAAQ,SAAS,KAAK,CAAC,CAAC,KAAK,YAAY,CAAC,CAAC,KAAK,eAAe,CAAC,CAAC,KAAK,IAC/E,EACJ;CAEA,IAAI,IAAI,SAAS,WAAW,GAAG;CAC/B,IAAI;EACF,MAAM,WAAW,MAAM,MAAM,kDAAkD;GAC7E,QAAQ;GACR,SAAS;IACP,eAAe,UAAU;IACzB,gBAAgB;IAChB,oBAAoB;IACpB,6BAA6B;IAC7B,mBAAmB;GACrB;GACA,MAAM,KAAK,UAAU,GAAG;EAC1B,CAAC;EAED,IAAI,SAAS,IAAI;EACjB,MAAM,EAAE,WAAW;EACnB,MAAM,UAAU,MAAM,SAAS,KAAK;EACpC,QAAQ,MAAM,+CAA+C,OAAO,UAAU,SAAS;CACzF,SAAS,OAAO;EACd,QAAQ,MAAM,sDAAsD,KAAK;CAC3E;AACF"}
@@ -1,8 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_third_parties_ignored_events = require("../third-parties/ignored-events.cjs");
3
- const require_click_id_index = require("../click-id/index.cjs");
4
3
  const require_track_fbq = require("../track/fbq.cjs");
5
4
  const require_server_action_source = require("./action-source.cjs");
5
+ const require_server_click_ids = require("./click-ids.cjs");
6
6
  const require_server_page_location = require("./page-location.cjs");
7
7
  let _shware_utils = require("@shware/utils");
8
8
  let node_crypto = require("node:crypto");
@@ -127,9 +127,10 @@ function getUserData(tags, data, eventTimeMs) {
127
127
  if (data.user_agent) userData.client_user_agent = data.user_agent;
128
128
  if (data.fb_login_id) userData.fb_login_id = data.fb_login_id;
129
129
  if (data.fb_page_id) userData.page_id = data.fb_page_id;
130
- if (tags.fbc) userData.fbc = tags.fbc;
131
- else if (tags.fbclid) userData.fbc = require_click_id_index.formatFbc(tags.fbclid, eventTimeMs);
132
- if (tags.fbp) userData.fbp = tags.fbp;
130
+ const fbc = require_server_click_ids.metaFbc(tags, eventTimeMs);
131
+ if (fbc) userData.fbc = fbc;
132
+ const fbp = require_server_click_ids.metaFbp(tags);
133
+ if (fbp) userData.fbp = fbp;
133
134
  if (tags.advertising_id) userData.madid = tags.advertising_id;
134
135
  if (tags.ip_address && typeof tags.ip_address === "string") userData.client_ip_address = tags.ip_address;
135
136
  return userData;