@shware/analytics 10.1.0 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -16
- package/dist/attribution/classify.cjs +39 -14
- package/dist/attribution/classify.cjs.map +1 -1
- package/dist/attribution/classify.d.cts +12 -6
- package/dist/attribution/classify.d.cts.map +1 -1
- package/dist/attribution/classify.d.mts +12 -6
- package/dist/attribution/classify.d.mts.map +1 -1
- package/dist/attribution/classify.mjs +39 -14
- package/dist/attribution/classify.mjs.map +1 -1
- package/dist/attribution/vocabulary.cjs +6 -2
- package/dist/attribution/vocabulary.cjs.map +1 -1
- package/dist/attribution/vocabulary.d.cts +6 -2
- package/dist/attribution/vocabulary.d.cts.map +1 -1
- package/dist/attribution/vocabulary.d.mts +6 -2
- package/dist/attribution/vocabulary.d.mts.map +1 -1
- package/dist/attribution/vocabulary.mjs +6 -2
- package/dist/attribution/vocabulary.mjs.map +1 -1
- package/dist/server/google-data-manager.cjs +11 -5
- package/dist/server/google-data-manager.cjs.map +1 -1
- package/dist/server/google-data-manager.d.cts +6 -3
- package/dist/server/google-data-manager.d.cts.map +1 -1
- package/dist/server/google-data-manager.d.mts +6 -3
- package/dist/server/google-data-manager.d.mts.map +1 -1
- package/dist/server/google-data-manager.mjs +11 -5
- package/dist/server/google-data-manager.mjs.map +1 -1
- package/dist/server/linkedin-conversions-api.cjs +17 -6
- package/dist/server/linkedin-conversions-api.cjs.map +1 -1
- package/dist/server/linkedin-conversions-api.d.cts.map +1 -1
- package/dist/server/linkedin-conversions-api.d.mts.map +1 -1
- package/dist/server/linkedin-conversions-api.mjs +17 -6
- package/dist/server/linkedin-conversions-api.mjs.map +1 -1
- package/dist/server/meta-capi.cjs +25 -7
- package/dist/server/meta-capi.cjs.map +1 -1
- package/dist/server/meta-capi.d.cts +14 -1
- package/dist/server/meta-capi.d.cts.map +1 -1
- package/dist/server/meta-capi.d.mts +14 -1
- package/dist/server/meta-capi.d.mts.map +1 -1
- package/dist/server/meta-capi.mjs +25 -8
- package/dist/server/meta-capi.mjs.map +1 -1
- package/dist/server/meta-conversions-api.cjs +9 -12
- package/dist/server/meta-conversions-api.cjs.map +1 -1
- package/dist/server/meta-conversions-api.d.cts.map +1 -1
- package/dist/server/meta-conversions-api.d.mts.map +1 -1
- package/dist/server/meta-conversions-api.mjs +9 -12
- package/dist/server/meta-conversions-api.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -32,10 +32,11 @@ const ENDPOINT = "https://datamanager.googleapis.com/v1/events:ingest";
|
|
|
32
32
|
/**
|
|
33
33
|
* Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the
|
|
34
34
|
* username's dots and everything from '+' on are removed; for every other domain they are kept.
|
|
35
|
-
*
|
|
35
|
+
* All whitespace goes — "leading, trailing and intermediate".
|
|
36
|
+
* https://developers.google.com/data-manager/api/devguides/concepts/formatting
|
|
36
37
|
*/
|
|
37
38
|
function normalizeEmail(input) {
|
|
38
|
-
const email = input.
|
|
39
|
+
const email = input.replace(/\s+/g, "").toLowerCase();
|
|
39
40
|
const at = email.lastIndexOf("@");
|
|
40
41
|
if (at === -1) return email;
|
|
41
42
|
let username = email.slice(0, at);
|
|
@@ -62,8 +63,10 @@ function getUserIdentifiers(data) {
|
|
|
62
63
|
}
|
|
63
64
|
/**
|
|
64
65
|
* Builds one Event, or undefined when no conversion action is configured for the event's name.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
66
|
+
* The gclid goes with the gbraid when there are both — "we recommend setting both the GCLID and
|
|
67
|
+
* GBRAID" — and otherwise one click id: gclid, else gbraid, else wbraid (nothing documents a
|
|
68
|
+
* wbraid next to a gclid). https://developers.google.com/google-ads/api/docs/conversions/upload-clicks
|
|
69
|
+
* Unlike
|
|
67
70
|
* the legacy upload, an event with no click id at all is still worth sending when it carries
|
|
68
71
|
* user identifiers — that is the enhanced-conversions match path.
|
|
69
72
|
*/
|
|
@@ -80,7 +83,10 @@ function getDataManagerEvent(event, config, data = {}, actionType = "webpage") {
|
|
|
80
83
|
currency: normalizeCurrency(event.properties?.currency)
|
|
81
84
|
};
|
|
82
85
|
const { gclid, gbraid, wbraid } = googleClickIds(event.tags);
|
|
83
|
-
if (gclid) dmEvent.adIdentifiers =
|
|
86
|
+
if (gclid) dmEvent.adIdentifiers = gbraid ? {
|
|
87
|
+
gclid,
|
|
88
|
+
gbraid
|
|
89
|
+
} : { gclid };
|
|
84
90
|
else if (gbraid) dmEvent.adIdentifiers = { gbraid };
|
|
85
91
|
else if (wbraid) dmEvent.adIdentifiers = { wbraid };
|
|
86
92
|
const userIdentifiers = getUserIdentifiers(data);
|
|
@@ -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';\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\n/**\n * The kind of conversion action the events go to, which decides `eventSource`:\n * - `webpage` (default): the action the gtag tag reports to, as in the hybrid deployment this\n * module is built for. Its `eventSource` is optional and, when set, \"must be WEB\" — any other\n * value fails the request, and the request with it. Web events say `WEB`, others say nothing.\n * - `offline`: an action created for uploads (`UPLOAD_CLICKS`), where `eventSource` is required\n * and any value goes: `WEB`, `APP` or `OTHER` from the event's platform.\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/online/send-events\n */\nexport type GoogleAdsActionType = 'webpage' | 'offline';\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n /** The kind of conversion action `config` points at; `webpage` by default. */\n actionType?: GoogleAdsActionType;\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 /** Absent for a non-web event sent to a `webpage` action, which accepts `WEB` only. */\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 actionType: GoogleAdsActionType = 'webpage'\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:\n source === 'web'\n ? 'WEB'\n : actionType === 'webpage'\n ? undefined\n : source === 'app'\n ? 'APP'\n : '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, options.actionType))\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;;;;;;AAmDjB,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,UAAkB,WAAW,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;;;;;;;;AAuBA,SAAgB,oBAEd,OACA,QACA,OAAyB,CAAC,GAC1B,aAAkC,WACJ;CAC9B,MAAM,SAAS,OAAO,MAAM;CAC5B,IAAI,CAAC,QAAQ,OAAO,KAAA;CAEpB,MAAM,SAAS,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,aACE,WAAW,QACP,QACA,eAAe,YACb,KAAA,IACA,WAAW,QACT,QACA;EAIV,iBAAiB,OAAO,MAAM,YAAY,UAAU,WAAW,MAAM,WAAW,QAAQ;EACxF,UAAU,kBAAkB,MAAM,YAAY,QAAQ;CACxD;CAEA,MAAM,EAAE,OAAO,QAAQ,WAAW,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,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CACvD,KAAK,UAAU,oBAAoB,OAAO,QAAQ,MAAM,QAAQ,UAAU,CAAC,CAAC,CAC5E,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,MAAM,MAAM,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
|
+
{"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\n/**\n * The kind of conversion action the events go to, which decides `eventSource`:\n * - `webpage` (default): the action the gtag tag reports to, as in the hybrid deployment this\n * module is built for. Its `eventSource` is optional and, when set, \"must be WEB\" — any other\n * value fails the request, and the request with it. Web events say `WEB`, others say nothing.\n * - `offline`: an action created for uploads (`UPLOAD_CLICKS`), where `eventSource` is required\n * and any value goes: `WEB`, `APP` or `OTHER` from the event's platform.\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/online/send-events\n */\nexport type GoogleAdsActionType = 'webpage' | 'offline';\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n /** The kind of conversion action `config` points at; `webpage` by default. */\n actionType?: GoogleAdsActionType;\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 * All whitespace goes — \"leading, trailing and intermediate\".\n * https://developers.google.com/data-manager/api/devguides/concepts/formatting\n */\nexport function normalizeEmail(input: string): string {\n const email = input.replace(/\\s+/g, '').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 /** Absent for a non-web event sent to a `webpage` action, which accepts `WEB` only. */\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 * The gclid goes with the gbraid when there are both — \"we recommend setting both the GCLID and\n * GBRAID\" — and otherwise one click id: gclid, else gbraid, else wbraid (nothing documents a\n * wbraid next to a gclid). https://developers.google.com/google-ads/api/docs/conversions/upload-clicks\n * 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 actionType: GoogleAdsActionType = 'webpage'\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:\n source === 'web'\n ? 'WEB'\n : actionType === 'webpage'\n ? undefined\n : source === 'app'\n ? 'APP'\n : '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 = gbraid ? { gclid, gbraid } : { 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, options.actionType))\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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"}
|
|
@@ -4,6 +4,7 @@ const require_utils_field = require("../utils/field.cjs");
|
|
|
4
4
|
const require_server_click_ids = require("./click-ids.cjs");
|
|
5
5
|
let _shware_utils = require("@shware/utils");
|
|
6
6
|
let node_crypto = require("node:crypto");
|
|
7
|
+
let node_net = require("node:net");
|
|
7
8
|
//#region src/server/linkedin-conversions-api.ts
|
|
8
9
|
/**
|
|
9
10
|
* Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder
|
|
@@ -53,12 +54,22 @@ async function sendEvents(accessToken, config, events, data = {}) {
|
|
|
53
54
|
idValue: hashEmail(email)
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
if (data.ip_address && (0, node_net.isIPv4)(data.ip_address)) userIds.push({
|
|
58
|
+
idType: "PLAINTEXT_IP_ADDRESS",
|
|
59
|
+
idValue: data.ip_address
|
|
60
|
+
});
|
|
61
|
+
const eventIds = (event) => {
|
|
62
|
+
const ids = [];
|
|
63
|
+
const fatId = require_server_click_ids.linkedinFatId(event.tags);
|
|
64
|
+
if (fatId) ids.push({
|
|
59
65
|
idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
|
|
60
|
-
idValue:
|
|
61
|
-
}
|
|
66
|
+
idValue: fatId
|
|
67
|
+
});
|
|
68
|
+
if (event.platform === "android" && event.tags.advertising_id) ids.push({
|
|
69
|
+
idType: "GOOGLE_AID",
|
|
70
|
+
idValue: event.tags.advertising_id
|
|
71
|
+
});
|
|
72
|
+
return ids;
|
|
62
73
|
};
|
|
63
74
|
const dto = { elements: events.filter((event) => eventNames.includes(event.name) && !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => ({
|
|
64
75
|
eventId: event.id,
|
|
@@ -69,7 +80,7 @@ async function sendEvents(accessToken, config, events, data = {}) {
|
|
|
69
80
|
amount: event.properties?.value?.toString() ?? "0"
|
|
70
81
|
},
|
|
71
82
|
user: {
|
|
72
|
-
userIds: [...
|
|
83
|
+
userIds: [...eventIds(event), ...userIds],
|
|
73
84
|
userInfo,
|
|
74
85
|
externalIds
|
|
75
86
|
}
|
|
@@ -1 +1 @@
|
|
|
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\n/**\n * `conversionHappenedAt` must be \"within the past 90 days\", and one invalid record makes \"all\n * records fail\"; such an event is left out, with a minute's margin for the trip.\n */\nconst MAX_EVENT_AGE_MS = 90 * 24 * 60 * 60 * 1000 - 60 * 1000;\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 .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\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;;;;;AAQvF,MAAM,mBAAmB;AAEzB,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,QAAQ,UAAU,KAAK,IAAI,IAAI,KAAK,MAAM,MAAM,UAAU,KAAK,gBAAgB,CAAC,CAChF,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
|
+
{"version":3,"file":"linkedin-conversions-api.cjs","names":["createHash","getFirst","isIPv4","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 { isIPv4 } from 'node:net';\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';\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\n/**\n * `conversionHappenedAt` must be \"within the past 90 days\", and one invalid record makes \"all\n * records fail\"; such an event is left out, with a minute's margin for the trip.\n */\nconst MAX_EVENT_AGE_MS = 90 * 24 * 60 * 60 * 1000 - 60 * 1000;\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 // LinkedIn takes the IP as is (it salts and hashes it itself), IPv4 only.\n // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api\n if (data.ip_address && isIPv4(data.ip_address)) {\n userIds.push({ idType: 'PLAINTEXT_IP_ADDRESS', idValue: data.ip_address });\n }\n\n // The identifiers each event carries itself: the click id, and the Android advertising id.\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n const eventIds = (event: TrackEvent<any>): { idType: UserIdType; idValue: string }[] => {\n const ids: { idType: UserIdType; idValue: string }[] = [];\n const fatId = linkedinFatId(event.tags);\n if (fatId) ids.push({ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: fatId });\n if (event.platform === 'android' && event.tags.advertising_id) {\n ids.push({ idType: 'GOOGLE_AID', idValue: event.tags.advertising_id });\n }\n return ids;\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\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: [...eventIds(event), ...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":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linkedin-conversions-api.d.cts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"linkedin-conversions-api.d.cts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAgBK;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;wBAQxB,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":";;;;;;
|
|
1
|
+
{"version":3,"file":"linkedin-conversions-api.d.mts","names":[],"sources":["../../src/server/linkedin-conversions-api.ts"],"mappings":";;;;;;KAgBK;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;wBAQxB,WACpB,qBACA,QAAQ,0BAER,QAAQ,mBACR,OAAM,mBAAqB"}
|
|
@@ -3,6 +3,7 @@ import { getFirst } from "../utils/field.mjs";
|
|
|
3
3
|
import { linkedinFatId } from "./click-ids.mjs";
|
|
4
4
|
import { fetch } from "@shware/utils";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
import { isIPv4 } from "node:net";
|
|
6
7
|
//#region src/server/linkedin-conversions-api.ts
|
|
7
8
|
/**
|
|
8
9
|
* Conversions API Payload Builder: https://www.linkedin.com/developers/payload-builder
|
|
@@ -52,12 +53,22 @@ async function sendEvents(accessToken, config, events, data = {}) {
|
|
|
52
53
|
idValue: hashEmail(email)
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
if (data.ip_address && isIPv4(data.ip_address)) userIds.push({
|
|
57
|
+
idType: "PLAINTEXT_IP_ADDRESS",
|
|
58
|
+
idValue: data.ip_address
|
|
59
|
+
});
|
|
60
|
+
const eventIds = (event) => {
|
|
61
|
+
const ids = [];
|
|
62
|
+
const fatId = linkedinFatId(event.tags);
|
|
63
|
+
if (fatId) ids.push({
|
|
58
64
|
idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
|
|
59
|
-
idValue:
|
|
60
|
-
}
|
|
65
|
+
idValue: fatId
|
|
66
|
+
});
|
|
67
|
+
if (event.platform === "android" && event.tags.advertising_id) ids.push({
|
|
68
|
+
idType: "GOOGLE_AID",
|
|
69
|
+
idValue: event.tags.advertising_id
|
|
70
|
+
});
|
|
71
|
+
return ids;
|
|
61
72
|
};
|
|
62
73
|
const dto = { elements: events.filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => ({
|
|
63
74
|
eventId: event.id,
|
|
@@ -68,7 +79,7 @@ async function sendEvents(accessToken, config, events, data = {}) {
|
|
|
68
79
|
amount: event.properties?.value?.toString() ?? "0"
|
|
69
80
|
},
|
|
70
81
|
user: {
|
|
71
|
-
userIds: [...
|
|
82
|
+
userIds: [...eventIds(event), ...userIds],
|
|
72
83
|
userInfo,
|
|
73
84
|
externalIds
|
|
74
85
|
}
|
|
@@ -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, 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\n/**\n * `conversionHappenedAt` must be \"within the past 90 days\", and one invalid record makes \"all\n * records fail\"; such an event is left out, with a minute's margin for the trip.\n */\nconst MAX_EVENT_AGE_MS = 90 * 24 * 60 * 60 * 1000 - 60 * 1000;\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 .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\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;;;;;AAQvF,MAAM,mBAAmB;AAEzB,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,QAAQ,UAAU,KAAK,IAAI,IAAI,KAAK,MAAM,MAAM,UAAU,KAAK,gBAAgB,CAAC,CAChF,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
|
+
{"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 { isIPv4 } from 'node:net';\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';\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\n/**\n * `conversionHappenedAt` must be \"within the past 90 days\", and one invalid record makes \"all\n * records fail\"; such an event is left out, with a minute's margin for the trip.\n */\nconst MAX_EVENT_AGE_MS = 90 * 24 * 60 * 60 * 1000 - 60 * 1000;\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 // LinkedIn takes the IP as is (it salts and hashes it itself), IPv4 only.\n // https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api\n if (data.ip_address && isIPv4(data.ip_address)) {\n userIds.push({ idType: 'PLAINTEXT_IP_ADDRESS', idValue: data.ip_address });\n }\n\n // The identifiers each event carries itself: the click id, and the Android advertising id.\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n const eventIds = (event: TrackEvent<any>): { idType: UserIdType; idValue: string }[] => {\n const ids: { idType: UserIdType; idValue: string }[] = [];\n const fatId = linkedinFatId(event.tags);\n if (fatId) ids.push({ idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', idValue: fatId });\n if (event.platform === 'android' && event.tags.advertising_id) {\n ids.push({ idType: 'GOOGLE_AID', idValue: event.tags.advertising_id });\n }\n return ids;\n };\n\n const dto: CreateMultipleLinkedinEventsDTO = {\n elements: events\n .filter((event) => eventNames.includes(event.name) && !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\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: [...eventIds(event), ...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":";;;;;;;;;;;;;;;;AAmFA,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;;;;;AAQvF,MAAM,mBAAmB;AAEzB,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;CAIA,IAAI,KAAK,cAAc,OAAO,KAAK,UAAU,GAC3C,QAAQ,KAAK;EAAE,QAAQ;EAAwB,SAAS,KAAK;CAAW,CAAC;CAK3E,MAAM,YAAY,UAAsE;EACtF,MAAM,MAAiD,CAAC;EACxD,MAAM,QAAQ,cAAc,MAAM,IAAI;EACtC,IAAI,OAAO,IAAI,KAAK;GAAE,QAAQ;GAA0C,SAAS;EAAM,CAAC;EACxF,IAAI,MAAM,aAAa,aAAa,MAAM,KAAK,gBAC7C,IAAI,KAAK;GAAE,QAAQ;GAAc,SAAS,MAAM,KAAK;EAAe,CAAC;EAEvE,OAAO;CACT;CAEA,MAAM,MAAuC,EAC3C,UAAU,OACP,QAAQ,UAAU,WAAW,SAAS,MAAM,IAAI,KAAK,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CAC1F,QAAQ,UAAU,KAAK,IAAI,IAAI,KAAK,MAAM,MAAM,UAAU,KAAK,gBAAgB,CAAC,CAChF,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,SAAS,KAAK,GAAG,GAAG,OAAO;GACxC;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"}
|
|
@@ -180,10 +180,19 @@ function getCustomData({ name, properties }) {
|
|
|
180
180
|
* decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS
|
|
181
181
|
* version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.
|
|
182
182
|
*/
|
|
183
|
-
|
|
183
|
+
/**
|
|
184
|
+
* The `extinfo` version of an app event's OS — `i2` for iOS, `a2` for Android, the only two Meta
|
|
185
|
+
* documents. Undefined for any other OS (a desktop app): such an event cannot carry the
|
|
186
|
+
* `app_data` an app event requires, and is sent as `other`.
|
|
187
|
+
* https://developers.facebook.com/docs/marketing-api/conversions-api/app-events
|
|
188
|
+
*/
|
|
189
|
+
function metaExtinfoVersion(tags) {
|
|
190
|
+
if (tags.os_name === "iOS" || tags.os_name === "iPadOS") return "i2";
|
|
191
|
+
if (tags.os_name === "Android") return "a2";
|
|
192
|
+
}
|
|
193
|
+
function getAppData(tags, appPackageName, version) {
|
|
184
194
|
const extinfo = {};
|
|
185
|
-
|
|
186
|
-
else if (tags.os_name === "Android") extinfo[0] = "a2";
|
|
195
|
+
extinfo[0] = version;
|
|
187
196
|
extinfo[1] = appPackageName;
|
|
188
197
|
const shortVersion = tags.release?.split(".").at(0);
|
|
189
198
|
if (shortVersion) extinfo[2] = shortVersion;
|
|
@@ -194,8 +203,10 @@ function getAppData(tags, appPackageName) {
|
|
|
194
203
|
if (tags.screen_width) extinfo[9] = tags.screen_width;
|
|
195
204
|
if (tags.screen_height) extinfo[10] = tags.screen_height;
|
|
196
205
|
if (tags.device_pixel_ratio) extinfo[11] = tags.device_pixel_ratio.toString();
|
|
197
|
-
const appData = {
|
|
198
|
-
|
|
206
|
+
const appData = {
|
|
207
|
+
extinfo,
|
|
208
|
+
advertiser_tracking_enabled: !!tags.advertising_id
|
|
209
|
+
};
|
|
199
210
|
if (tags.install_referrer) appData.install_referrer = tags.install_referrer;
|
|
200
211
|
return appData;
|
|
201
212
|
}
|
|
@@ -209,15 +220,20 @@ function getCapiEvent(event, data, appPackageName) {
|
|
|
209
220
|
const eventTimeMs = new Date(event.created_at).getTime();
|
|
210
221
|
const [_, eventName] = require_track_fbq.mapFBEvent(event.name, event.properties);
|
|
211
222
|
const source = require_server_action_source.resolveActionSource(event.platform);
|
|
223
|
+
const version = metaExtinfoVersion(event.tags);
|
|
224
|
+
const app = source === "app" && appPackageName && version ? {
|
|
225
|
+
packageName: appPackageName,
|
|
226
|
+
version
|
|
227
|
+
} : void 0;
|
|
212
228
|
const capiEvent = {
|
|
213
229
|
event_name: eventName,
|
|
214
230
|
event_time: Math.round(eventTimeMs / 1e3),
|
|
215
231
|
event_id: event.tags.idempotency_key ?? event.id,
|
|
216
|
-
action_source:
|
|
232
|
+
action_source: app ? "app" : source === "web" ? "website" : "other",
|
|
217
233
|
user_data: getUserData(event.tags, data, eventTimeMs),
|
|
218
234
|
custom_data: getCustomData(event)
|
|
219
235
|
};
|
|
220
|
-
if (
|
|
236
|
+
if (app) capiEvent.app_data = getAppData(event.tags, app.packageName, app.version);
|
|
221
237
|
const eventSourceUrl = require_server_page_location.pageLocation(event.tags);
|
|
222
238
|
if (eventSourceUrl) capiEvent.event_source_url = eventSourceUrl;
|
|
223
239
|
return capiEvent;
|
|
@@ -236,6 +252,7 @@ const MAX_EVENT_AGE_MS = 60474e4;
|
|
|
236
252
|
async function sendEvents(accessToken, pixelId, events, data = {}, options = {}) {
|
|
237
253
|
const capiEvents = events.filter((event) => !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS).map((event) => getCapiEvent(event, data, options.appPackageName));
|
|
238
254
|
if (capiEvents.length === 0) return void 0;
|
|
255
|
+
if (!data.user_agent && capiEvents.some((event) => event.action_source === "website")) console.warn("Meta conversion: website events sent without client_user_agent (data.user_agent)");
|
|
239
256
|
const version = options.apiVersion ?? API_VERSION;
|
|
240
257
|
try {
|
|
241
258
|
const response = await (0, _shware_utils.fetch)(`https://graph.facebook.com/${version}/${pixelId}/events`, {
|
|
@@ -263,6 +280,7 @@ async function sendEvents(accessToken, pixelId, events, data = {}, options = {})
|
|
|
263
280
|
}
|
|
264
281
|
//#endregion
|
|
265
282
|
exports.getCapiEvent = getCapiEvent;
|
|
283
|
+
exports.metaExtinfoVersion = metaExtinfoVersion;
|
|
266
284
|
exports.sendEvents = sendEvents;
|
|
267
285
|
|
|
268
286
|
//# sourceMappingURL=meta-capi.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-capi.cjs","names":["ParamBuilder","createHash","PII_DATA_TYPE","metaFbc","metaFbp","mapFBEvent","resolveActionSource","pageLocation","IGNORED_EVENTS","fetch"],"sources":["../../src/server/meta-capi.ts"],"sourcesContent":["/**\n * Meta Conversions API sender built on `capi-param-builder-nodejs` and plain `fetch`, replacing\n * the 31MB `facebook-nodejs-business-sdk` with ~0.5MB of parameter building. The SDK-based\n * sender survives untouched at `@shware/analytics/server/legacy`; this module is its drop-in\n * successor.\n *\n * The wire payload is byte-identical to what the business SDK's `ServerEvent.normalize()`\n * produces — enforced by a differential test that runs both builders over the same events.\n * Where the two libraries normalize differently (the business SDK keeps punctuation in names,\n * spaces in multi-word last names, accents in cities; the param builder strips them per the\n * current documented rules), this module follows the business SDK so that switching senders\n * cannot change a single hash Meta receives. The param builder is used where the two provably\n * agree — email, phone, gender, country — and its adoption-tracking appendix (`<hash>.<token>`)\n * is stripped for the same reason.\n *\n * Two deliberate behavior differences from the business SDK, both on invalid input only:\n * an invalid field (malformed email, non-ISO country, letters in a phone) is dropped from\n * user_data instead of throwing inside `execute()` and costing the whole batch; an invalid\n * currency is uppercased and forwarded instead of rejected. Valid input is unaffected.\n *\n * https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { PII_DATA_TYPE, ParamBuilder } from 'capi-param-builder-nodejs';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\n/** Matches the Graph API version the pinned business SDK speaks (`FacebookAdsApi.VERSION`). */\nconst API_VERSION = 'v24.0';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\n/**\n * Lazy for the same reason as the session and the token bucket (see setup/session.ts):\n * Cloudflare Workers refuse some work in module scope, and nothing here is per-request state.\n */\nlet paramBuilder: ParamBuilder | undefined;\n\n/**\n * Normalize and hash one PII value through the param builder, stripped to the bare SHA-256 the\n * business SDK sends: the builder appends a `.<language-token>` adoption marker that Meta's\n * endpoint tolerates but the business SDK never produced. Pre-hashed input passes through, as\n * it does in the business SDK.\n */\nfunction pii(value: string | undefined, dataType: string): string | undefined {\n if (!value) return undefined;\n const built = (paramBuilder ??= new ParamBuilder()).getNormalizedAndHashedPII(value, dataType);\n return built?.split('.').at(0) ?? undefined;\n}\n\nconst SHA256_OR_MD5 = /^[a-f0-9]{64}$|^[a-f0-9]{32}$/;\n\n/**\n * The business SDK's `normalizeAndHash` for the fields where the param builder normalizes\n * differently (names, city, state, zip): trim + lowercase, pass a pre-hashed value through,\n * apply the field rule, SHA-256. Field rules are copied from `ServerSideUtils` verbatim so the\n * differential test can hold the outputs equal.\n */\nfunction hashed(\n input: string | undefined,\n rule: (lowered: string) => string | undefined\n): string | undefined {\n if (!input) return undefined;\n const lowered = input.trim().toLowerCase();\n if (!lowered) return undefined;\n if (SHA256_OR_MD5.test(lowered)) return lowered;\n const normalized = rule(lowered);\n if (normalized === undefined) return undefined;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nconst nameRule = (v: string) => v;\nconst f5Rule = (v: string) => (v.length <= 5 ? v : v.slice(0, 5));\nconst cityStateRule = (v: string) => v.replace(/[0-9\\s().-]/g, '');\nconst zipRule = (v: string) => {\n const zip = v.replace(/\\s/g, '').split('-', 1)[0];\n return zip.length < 2 ? undefined : zip;\n};\n\n/**\n * Every multi-value field is deduplicated after hashing, order preserved — the SDK runs each\n * through `dedupArray` (a Set), `external_id` included.\n */\nconst dedupe = (hashes: (string | undefined)[]) =>\n Array.from(new Set(hashes.filter((hash): hash is string => hash !== undefined)));\n\nconst list = (hashes: (string | undefined)[]) => {\n const deduped = dedupe(hashes);\n return deduped.length > 0 ? deduped : undefined;\n};\n\ninterface CapiUserData {\n em?: string[];\n ph?: string[];\n ge?: string[];\n fn?: string[];\n ln?: string[];\n ct?: string[];\n st?: string[];\n zp?: string[];\n country?: string[];\n external_id?: string[];\n client_ip_address?: string;\n client_user_agent?: string;\n fbc?: string;\n fbp?: string;\n fb_login_id?: string;\n page_id?: string;\n f5first?: string;\n f5last?: string;\n dobd?: string;\n dobm?: string;\n doby?: string;\n madid?: string;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number): CapiUserData {\n const userData: CapiUserData = {};\n\n if (data.email) {\n const emails = Array.isArray(data.email) ? data.email : [data.email];\n userData.em = list(emails.map((email) => pii(email, PII_DATA_TYPE.EMAIL)));\n }\n if (data.phone_number) {\n const phones = Array.isArray(data.phone_number) ? data.phone_number : [data.phone_number];\n userData.ph = list(phones.map((phone) => pii(phone, PII_DATA_TYPE.PHONE)));\n }\n if (data.gender === 'female' || data.gender === 'male') {\n userData.ge = list([pii(data.gender === 'female' ? 'f' : 'm', PII_DATA_TYPE.GENDER)]);\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n // The SDK sets these lists unconditionally in the multi-address branch, so an all-empty\n // column still goes out as `[]` — kept for byte-equality with the current sender.\n userData.fn = dedupe(data.address.map((a) => hashed(a.first_name, nameRule)));\n userData.ln = dedupe(data.address.map((a) => hashed(a.last_name, nameRule)));\n userData.ct = dedupe(data.address.map((a) => hashed(a.city, cityStateRule)));\n userData.st = dedupe(data.address.map((a) => hashed(a.region, cityStateRule)));\n userData.zp = dedupe(data.address.map((a) => hashed(a.postal_code, zipRule)));\n userData.country = dedupe(\n data.address.map((a) => pii(normalizeCountry(a.country), PII_DATA_TYPE.COUNTRY))\n );\n } else {\n userData.fn = list([hashed(data.address.first_name, nameRule)]);\n userData.ln = list([hashed(data.address.last_name, nameRule)]);\n userData.ct = list([hashed(data.address.city, cityStateRule)]);\n userData.st = list([hashed(data.address.region, cityStateRule)]);\n userData.zp = list([hashed(data.address.postal_code, zipRule)]);\n userData.country = list([pii(normalizeCountry(data.address.country), PII_DATA_TYPE.COUNTRY)]);\n // The 5-character prefixes, exactly as the current sender builds them: sliced first, then\n // normalized. Only the single-address branch sets them, as before.\n userData.f5first = hashed(data.address.first_name?.slice(0, 5), f5Rule);\n userData.f5last = hashed(data.address.last_name?.slice(0, 5), f5Rule);\n }\n }\n if (data.birthday) {\n const { year, month, day } = data.birthday;\n if (/^\\d{4}$/.test(year.toString())) {\n userData.doby = createHash('sha256').update(year.toString()).digest('hex');\n }\n if (month >= 1 && month <= 12) {\n userData.dobm = createHash('sha256').update(month.toString().padStart(2, '0')).digest('hex');\n }\n if (day >= 1 && day <= 31) {\n userData.dobd = createHash('sha256').update(day.toString().padStart(2, '0')).digest('hex');\n }\n }\n if (data.user_id && data.user_id.length !== 0) {\n // Unhashed, as the business SDK sends it. Meta documents hashing as recommended, not\n // required; changing it here would break continuity with every event sent so far.\n userData.external_id = [data.user_id];\n }\n if (data.ip_address) {\n userData.client_ip_address = data.ip_address;\n }\n if (data.user_agent) {\n userData.client_user_agent = data.user_agent;\n }\n if (data.fb_login_id) {\n userData.fb_login_id = data.fb_login_id;\n }\n if (data.fb_page_id) {\n userData.page_id = data.fb_page_id;\n }\n\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.fbc = fbc;\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.fbp = fbp;\n }\n if (tags.advertising_id) {\n userData.madid = tags.advertising_id;\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.client_ip_address = tags.ip_address;\n }\n\n return userData;\n}\n\nconst DELIVERY_CATEGORIES = ['in_store', 'curbside', 'home_delivery'];\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>): Record<string, unknown> {\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n predicted_ltv,\n num_items,\n search_string,\n status,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n\n const customData: Record<string, unknown> = {};\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null && Number.isFinite(Number.parseFloat(String(value)))) {\n customData.value = Number.parseFloat(String(value));\n }\n if (currency) {\n // The business SDK also validates against the ISO 4217 list and throws; here an unknown\n // code is forwarded uppercased rather than costing the batch.\n customData.currency = currency\n .trim()\n .toUpperCase()\n .replace(/[^A-Z]/g, '');\n }\n if (content_name) customData.content_name = content_name;\n if (content_category) customData.content_category = content_category;\n if (content_ids) customData.content_ids = content_ids;\n if (contents) {\n customData.contents = contents.map((c) => {\n const content: Record<string, unknown> = {};\n if (c.id) content.id = c.id;\n if (c.quantity) content.quantity = c.quantity;\n if (c.item_price) content.item_price = c.item_price;\n if (c.title) content.title = c.title;\n if (c.description) content.description = c.description;\n if (c.brand) content.brand = c.brand;\n if (c.category) content.category = c.category;\n const delivery = c.delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) content.delivery_category = delivery;\n return content;\n });\n }\n if (content_type) customData.content_type = content_type;\n if (predicted_ltv) customData.predicted_ltv = predicted_ltv;\n if (num_items) customData.num_items = num_items;\n if (search_string) customData.search_string = search_string;\n if (status) customData.status = status.toString();\n const delivery = delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) customData.delivery_category = delivery;\n\n return { ...customData, ...custom_properties };\n}\n\n/**\n * `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset\n * indexes out when it serializes; the documented 16-element array is what the Graph endpoint\n * decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS\n * version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.\n */\nfunction getAppData(tags: TrackTags, appPackageName: string): Record<string, unknown> {\n const extinfo: Record<number, string | number> = {};\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') {\n extinfo[0] = 'i2';\n } else if (tags.os_name === 'Android') {\n extinfo[0] = 'a2';\n }\n extinfo[1] = appPackageName;\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) extinfo[2] = shortVersion;\n if (tags.release) extinfo[3] = tags.release;\n if (tags.os_version) extinfo[4] = tags.os_version;\n if (tags.device_model_id) extinfo[5] = tags.device_model_id;\n if (tags.language) extinfo[6] = tags.language;\n if (tags.screen_width) extinfo[9] = tags.screen_width;\n if (tags.screen_height) extinfo[10] = tags.screen_height;\n if (tags.device_pixel_ratio) extinfo[11] = tags.device_pixel_ratio.toString();\n\n const appData: Record<string, unknown> = { extinfo };\n if (tags.advertising_id) appData.advertiser_tracking_enabled = true;\n if (tags.install_referrer) appData.install_referrer = tags.install_referrer;\n return appData;\n}\n\n/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */\nexport interface CapiEvent {\n event_name: string;\n event_time: number;\n event_id: string;\n event_source_url?: string;\n action_source: 'app' | 'website' | 'other';\n user_data: CapiUserData;\n custom_data: Record<string, unknown>;\n app_data?: Record<string, unknown>;\n}\n\n/**\n * `action_source` is derived from `event.platform` alone: an OS platform is an app, `web` is a\n * website, and everything else — including a backend-built offline conversion, which should\n * declare `platform: 'unknown'` — lands in Meta's `other`, exactly where the business-SDK\n * sender put an explicit `'offline'` too.\n */\nexport function getCapiEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string\n): CapiEvent {\n const eventTimeMs = new Date(event.created_at).getTime();\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const source = resolveActionSource(event.platform);\n\n const capiEvent: CapiEvent = {\n event_name: eventName,\n event_time: Math.round(eventTimeMs / 1000),\n event_id: event.tags.idempotency_key ?? event.id,\n // `action_source` is required; an offline conversion and an undeterminable platform both\n // land in Meta's catch-all, exactly as in the business-SDK sender.\n action_source: source === 'app' ? 'app' : source === 'web' ? 'website' : 'other',\n user_data: getUserData(event.tags, data, eventTimeMs),\n custom_data: getCustomData(event),\n };\n if (source === 'app' && appPackageName) {\n capiEvent.app_data = getAppData(event.tags, appPackageName);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n capiEvent.event_source_url = eventSourceUrl;\n }\n return capiEvent;\n}\n\nexport interface MetaConversionsOptions {\n /** Attaches `app_data`/extinfo to events whose platform resolves to an app. */\n appPackageName?: string;\n /** Routes the batch to Events Manager's Test Events tab instead of production. */\n testEventCode?: string;\n /** Graph API version, e.g. 'v24.0'. */\n apiVersion?: string;\n}\n\nexport interface MetaConversionsResponse {\n events_received?: number;\n messages?: string[];\n fbtrace_id?: string;\n}\n\n/**\n * An `event_time` over 7 days old fails the whole request — \"we return an error for the entire\n * request and process no events\" — so such an event is left out; a minute's margin for the trip.\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event\n */\nconst MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60 * 1000 - 60 * 1000;\n\n/**\n * The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):\n * same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token\n * travels in the JSON body, never in the URL, and is never logged.\n */\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: MetaConversionsOptions = {}\n): Promise<MetaConversionsResponse | undefined> {\n const capiEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\n .map((event) => getCapiEvent(event, data, options.appPackageName));\n if (capiEvents.length === 0) return undefined;\n\n const version = options.apiVersion ?? API_VERSION;\n try {\n const response = await fetch(`https://graph.facebook.com/${version}/${pixelId}/events`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n data: capiEvents,\n test_event_code: options.testEventCode,\n access_token: accessToken,\n }),\n });\n if (response.ok) return (await response.json()) as MetaConversionsResponse;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Meta conversion, status: ${status}, body: ${message}`);\n return undefined;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Meta conversion, network error: ${message}`);\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"meta-capi.cjs","names":["ParamBuilder","createHash","PII_DATA_TYPE","metaFbc","metaFbp","mapFBEvent","resolveActionSource","pageLocation","IGNORED_EVENTS","fetch"],"sources":["../../src/server/meta-capi.ts"],"sourcesContent":["/**\n * Meta Conversions API sender built on `capi-param-builder-nodejs` and plain `fetch`, replacing\n * the 31MB `facebook-nodejs-business-sdk` with ~0.5MB of parameter building. The SDK-based\n * sender survives untouched at `@shware/analytics/server/legacy`; this module is its drop-in\n * successor.\n *\n * The wire payload is byte-identical to what the business SDK's `ServerEvent.normalize()`\n * produces — enforced by a differential test that runs both builders over the same events.\n * Where the two libraries normalize differently (the business SDK keeps punctuation in names,\n * spaces in multi-word last names, accents in cities; the param builder strips them per the\n * current documented rules), this module follows the business SDK so that switching senders\n * cannot change a single hash Meta receives. The param builder is used where the two provably\n * agree — email, phone, gender, country — and its adoption-tracking appendix (`<hash>.<token>`)\n * is stripped for the same reason.\n *\n * Two deliberate behavior differences from the business SDK, both on invalid input only:\n * an invalid field (malformed email, non-ISO country, letters in a phone) is dropped from\n * user_data instead of throwing inside `execute()` and costing the whole batch; an invalid\n * currency is uppercased and forwarded instead of rejected. Valid input is unaffected.\n *\n * https://developers.facebook.com/docs/marketing-api/conversions-api/using-the-api\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/customer-information-parameters\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { PII_DATA_TYPE, ParamBuilder } from 'capi-param-builder-nodejs';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport { mapFBEvent } from '../track/fbq';\nimport type { TrackEvent, TrackTags, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { metaFbc, metaFbp } from './click-ids';\nimport { pageLocation } from './page-location';\n\n/** Matches the Graph API version the pinned business SDK speaks (`FacebookAdsApi.VERSION`). */\nconst API_VERSION = 'v24.0';\n\nconst USER_ASSIGNED_COUNTRIES: string[] = ['xk'];\nfunction normalizeCountry(input: string | undefined): string | undefined {\n const country = input?.split(/[-_]/).at(0)?.toLowerCase();\n if (!country) return undefined;\n return USER_ASSIGNED_COUNTRIES.includes(country) ? undefined : country;\n}\n\n/**\n * Lazy for the same reason as the session and the token bucket (see setup/session.ts):\n * Cloudflare Workers refuse some work in module scope, and nothing here is per-request state.\n */\nlet paramBuilder: ParamBuilder | undefined;\n\n/**\n * Normalize and hash one PII value through the param builder, stripped to the bare SHA-256 the\n * business SDK sends: the builder appends a `.<language-token>` adoption marker that Meta's\n * endpoint tolerates but the business SDK never produced. Pre-hashed input passes through, as\n * it does in the business SDK.\n */\nfunction pii(value: string | undefined, dataType: string): string | undefined {\n if (!value) return undefined;\n const built = (paramBuilder ??= new ParamBuilder()).getNormalizedAndHashedPII(value, dataType);\n return built?.split('.').at(0) ?? undefined;\n}\n\nconst SHA256_OR_MD5 = /^[a-f0-9]{64}$|^[a-f0-9]{32}$/;\n\n/**\n * The business SDK's `normalizeAndHash` for the fields where the param builder normalizes\n * differently (names, city, state, zip): trim + lowercase, pass a pre-hashed value through,\n * apply the field rule, SHA-256. Field rules are copied from `ServerSideUtils` verbatim so the\n * differential test can hold the outputs equal.\n */\nfunction hashed(\n input: string | undefined,\n rule: (lowered: string) => string | undefined\n): string | undefined {\n if (!input) return undefined;\n const lowered = input.trim().toLowerCase();\n if (!lowered) return undefined;\n if (SHA256_OR_MD5.test(lowered)) return lowered;\n const normalized = rule(lowered);\n if (normalized === undefined) return undefined;\n return createHash('sha256').update(normalized).digest('hex');\n}\n\nconst nameRule = (v: string) => v;\nconst f5Rule = (v: string) => (v.length <= 5 ? v : v.slice(0, 5));\nconst cityStateRule = (v: string) => v.replace(/[0-9\\s().-]/g, '');\nconst zipRule = (v: string) => {\n const zip = v.replace(/\\s/g, '').split('-', 1)[0];\n return zip.length < 2 ? undefined : zip;\n};\n\n/**\n * Every multi-value field is deduplicated after hashing, order preserved — the SDK runs each\n * through `dedupArray` (a Set), `external_id` included.\n */\nconst dedupe = (hashes: (string | undefined)[]) =>\n Array.from(new Set(hashes.filter((hash): hash is string => hash !== undefined)));\n\nconst list = (hashes: (string | undefined)[]) => {\n const deduped = dedupe(hashes);\n return deduped.length > 0 ? deduped : undefined;\n};\n\ninterface CapiUserData {\n em?: string[];\n ph?: string[];\n ge?: string[];\n fn?: string[];\n ln?: string[];\n ct?: string[];\n st?: string[];\n zp?: string[];\n country?: string[];\n external_id?: string[];\n client_ip_address?: string;\n client_user_agent?: string;\n fbc?: string;\n fbp?: string;\n fb_login_id?: string;\n page_id?: string;\n f5first?: string;\n f5last?: string;\n dobd?: string;\n dobm?: string;\n doby?: string;\n madid?: string;\n}\n\nfunction getUserData(tags: TrackTags, data: UserProvidedData, eventTimeMs: number): CapiUserData {\n const userData: CapiUserData = {};\n\n if (data.email) {\n const emails = Array.isArray(data.email) ? data.email : [data.email];\n userData.em = list(emails.map((email) => pii(email, PII_DATA_TYPE.EMAIL)));\n }\n if (data.phone_number) {\n const phones = Array.isArray(data.phone_number) ? data.phone_number : [data.phone_number];\n userData.ph = list(phones.map((phone) => pii(phone, PII_DATA_TYPE.PHONE)));\n }\n if (data.gender === 'female' || data.gender === 'male') {\n userData.ge = list([pii(data.gender === 'female' ? 'f' : 'm', PII_DATA_TYPE.GENDER)]);\n }\n if (data.address) {\n if (Array.isArray(data.address)) {\n // The SDK sets these lists unconditionally in the multi-address branch, so an all-empty\n // column still goes out as `[]` — kept for byte-equality with the current sender.\n userData.fn = dedupe(data.address.map((a) => hashed(a.first_name, nameRule)));\n userData.ln = dedupe(data.address.map((a) => hashed(a.last_name, nameRule)));\n userData.ct = dedupe(data.address.map((a) => hashed(a.city, cityStateRule)));\n userData.st = dedupe(data.address.map((a) => hashed(a.region, cityStateRule)));\n userData.zp = dedupe(data.address.map((a) => hashed(a.postal_code, zipRule)));\n userData.country = dedupe(\n data.address.map((a) => pii(normalizeCountry(a.country), PII_DATA_TYPE.COUNTRY))\n );\n } else {\n userData.fn = list([hashed(data.address.first_name, nameRule)]);\n userData.ln = list([hashed(data.address.last_name, nameRule)]);\n userData.ct = list([hashed(data.address.city, cityStateRule)]);\n userData.st = list([hashed(data.address.region, cityStateRule)]);\n userData.zp = list([hashed(data.address.postal_code, zipRule)]);\n userData.country = list([pii(normalizeCountry(data.address.country), PII_DATA_TYPE.COUNTRY)]);\n // The 5-character prefixes, exactly as the current sender builds them: sliced first, then\n // normalized. Only the single-address branch sets them, as before.\n userData.f5first = hashed(data.address.first_name?.slice(0, 5), f5Rule);\n userData.f5last = hashed(data.address.last_name?.slice(0, 5), f5Rule);\n }\n }\n if (data.birthday) {\n const { year, month, day } = data.birthday;\n if (/^\\d{4}$/.test(year.toString())) {\n userData.doby = createHash('sha256').update(year.toString()).digest('hex');\n }\n if (month >= 1 && month <= 12) {\n userData.dobm = createHash('sha256').update(month.toString().padStart(2, '0')).digest('hex');\n }\n if (day >= 1 && day <= 31) {\n userData.dobd = createHash('sha256').update(day.toString().padStart(2, '0')).digest('hex');\n }\n }\n if (data.user_id && data.user_id.length !== 0) {\n // Unhashed, as the business SDK sends it. Meta documents hashing as recommended, not\n // required; changing it here would break continuity with every event sent so far.\n userData.external_id = [data.user_id];\n }\n if (data.ip_address) {\n userData.client_ip_address = data.ip_address;\n }\n if (data.user_agent) {\n userData.client_user_agent = data.user_agent;\n }\n if (data.fb_login_id) {\n userData.fb_login_id = data.fb_login_id;\n }\n if (data.fb_page_id) {\n userData.page_id = data.fb_page_id;\n }\n\n const fbc = metaFbc(tags, eventTimeMs);\n if (fbc) {\n userData.fbc = fbc;\n }\n const fbp = metaFbp(tags);\n if (fbp) {\n userData.fbp = fbp;\n }\n if (tags.advertising_id) {\n userData.madid = tags.advertising_id;\n }\n if (tags.ip_address && typeof tags.ip_address === 'string') {\n userData.client_ip_address = tags.ip_address;\n }\n\n return userData;\n}\n\nconst DELIVERY_CATEGORIES = ['in_store', 'curbside', 'home_delivery'];\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData({ name, properties }: TrackEvent<any>): Record<string, unknown> {\n const [_, _name, fbEventProperties] = mapFBEvent(name, properties);\n const {\n value,\n currency,\n content_name,\n content_category,\n content_ids,\n contents,\n content_type,\n predicted_ltv,\n num_items,\n search_string,\n status,\n delivery_category,\n ...custom_properties\n } = fbEventProperties;\n\n const customData: Record<string, unknown> = {};\n // oxlint-disable-next-line typescript/no-unnecessary-condition -- custom event properties may carry `value: null` at runtime despite the declared type\n if (value !== undefined && value !== null && Number.isFinite(Number.parseFloat(String(value)))) {\n customData.value = Number.parseFloat(String(value));\n }\n if (currency) {\n // The business SDK also validates against the ISO 4217 list and throws; here an unknown\n // code is forwarded uppercased rather than costing the batch.\n customData.currency = currency\n .trim()\n .toUpperCase()\n .replace(/[^A-Z]/g, '');\n }\n if (content_name) customData.content_name = content_name;\n if (content_category) customData.content_category = content_category;\n if (content_ids) customData.content_ids = content_ids;\n if (contents) {\n customData.contents = contents.map((c) => {\n const content: Record<string, unknown> = {};\n if (c.id) content.id = c.id;\n if (c.quantity) content.quantity = c.quantity;\n if (c.item_price) content.item_price = c.item_price;\n if (c.title) content.title = c.title;\n if (c.description) content.description = c.description;\n if (c.brand) content.brand = c.brand;\n if (c.category) content.category = c.category;\n const delivery = c.delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) content.delivery_category = delivery;\n return content;\n });\n }\n if (content_type) customData.content_type = content_type;\n if (predicted_ltv) customData.predicted_ltv = predicted_ltv;\n if (num_items) customData.num_items = num_items;\n if (search_string) customData.search_string = search_string;\n if (status) customData.status = status.toString();\n const delivery = delivery_category?.trim().toLowerCase();\n if (delivery && DELIVERY_CATEGORIES.includes(delivery)) customData.delivery_category = delivery;\n\n return { ...customData, ...custom_properties };\n}\n\n/**\n * `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset\n * indexes out when it serializes; the documented 16-element array is what the Graph endpoint\n * decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS\n * version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.\n */\n/**\n * The `extinfo` version of an app event's OS — `i2` for iOS, `a2` for Android, the only two Meta\n * documents. Undefined for any other OS (a desktop app): such an event cannot carry the\n * `app_data` an app event requires, and is sent as `other`.\n * https://developers.facebook.com/docs/marketing-api/conversions-api/app-events\n */\nexport function metaExtinfoVersion(tags: TrackTags): 'i2' | 'a2' | undefined {\n if (tags.os_name === 'iOS' || tags.os_name === 'iPadOS') return 'i2';\n if (tags.os_name === 'Android') return 'a2';\n return undefined;\n}\n\nfunction getAppData(\n tags: TrackTags,\n appPackageName: string,\n version: 'i2' | 'a2'\n): Record<string, unknown> {\n const extinfo: Record<number, string | number> = {};\n extinfo[0] = version;\n extinfo[1] = appPackageName;\n const shortVersion = tags.release?.split('.').at(0);\n if (shortVersion) extinfo[2] = shortVersion;\n if (tags.release) extinfo[3] = tags.release;\n if (tags.os_version) extinfo[4] = tags.os_version;\n if (tags.device_model_id) extinfo[5] = tags.device_model_id;\n if (tags.language) extinfo[6] = tags.language;\n if (tags.screen_width) extinfo[9] = tags.screen_width;\n if (tags.screen_height) extinfo[10] = tags.screen_height;\n if (tags.device_pixel_ratio) extinfo[11] = tags.device_pixel_ratio.toString();\n\n // Required on every app event. An advertising id is only readable once the user allowed\n // tracking (ATT on iOS, the ad id setting on Android), so its presence is the answer.\n const appData: Record<string, unknown> = {\n extinfo,\n advertiser_tracking_enabled: !!tags.advertising_id,\n };\n if (tags.install_referrer) appData.install_referrer = tags.install_referrer;\n return appData;\n}\n\n/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */\nexport interface CapiEvent {\n event_name: string;\n event_time: number;\n event_id: string;\n event_source_url?: string;\n action_source: 'app' | 'website' | 'other';\n user_data: CapiUserData;\n custom_data: Record<string, unknown>;\n app_data?: Record<string, unknown>;\n}\n\n/**\n * `action_source` is derived from `event.platform` alone: an OS platform is an app, `web` is a\n * website, and everything else — including a backend-built offline conversion, which should\n * declare `platform: 'unknown'` — lands in Meta's `other`, exactly where the business-SDK\n * sender put an explicit `'offline'` too.\n */\nexport function getCapiEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData,\n appPackageName?: string\n): CapiEvent {\n const eventTimeMs = new Date(event.created_at).getTime();\n const [_, eventName] = mapFBEvent(event.name, event.properties);\n const source = resolveActionSource(event.platform);\n const version = metaExtinfoVersion(event.tags);\n const app =\n source === 'app' && appPackageName && version\n ? { packageName: appPackageName, version }\n : undefined;\n\n const capiEvent: CapiEvent = {\n event_name: eventName,\n event_time: Math.round(eventTimeMs / 1000),\n event_id: event.tags.idempotency_key ?? event.id,\n // `action_source` is required; an offline conversion and an undeterminable platform both\n // land in Meta's catch-all, exactly as in the business-SDK sender. So does an app event that\n // cannot carry the `app_data` Meta requires of one: no package name, or a desktop OS.\n action_source: app ? 'app' : source === 'web' ? 'website' : 'other',\n user_data: getUserData(event.tags, data, eventTimeMs),\n custom_data: getCustomData(event),\n };\n if (app) {\n capiEvent.app_data = getAppData(event.tags, app.packageName, app.version);\n }\n const eventSourceUrl = pageLocation(event.tags);\n if (eventSourceUrl) {\n capiEvent.event_source_url = eventSourceUrl;\n }\n return capiEvent;\n}\n\nexport interface MetaConversionsOptions {\n /** Attaches `app_data`/extinfo to events whose platform resolves to an app. */\n appPackageName?: string;\n /** Routes the batch to Events Manager's Test Events tab instead of production. */\n testEventCode?: string;\n /** Graph API version, e.g. 'v24.0'. */\n apiVersion?: string;\n}\n\nexport interface MetaConversionsResponse {\n events_received?: number;\n messages?: string[];\n fbtrace_id?: string;\n}\n\n/**\n * An `event_time` over 7 days old fails the whole request — \"we return an error for the entire\n * request and process no events\" — so such an event is left out; a minute's margin for the trip.\n * https://developers.facebook.com/docs/marketing-api/conversions-api/parameters/server-event\n */\nconst MAX_EVENT_AGE_MS = 7 * 24 * 60 * 60 * 1000 - 60 * 1000;\n\n/**\n * The lightweight counterpart to the legacy SDK sender (`@shware/analytics/server/legacy`):\n * same filtering, same payload, same never-throws contract, minus the 31MB SDK. The token\n * travels in the JSON body, never in the URL, and is never logged.\n */\nexport async function sendEvents(\n accessToken: string,\n pixelId: string,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: MetaConversionsOptions = {}\n): Promise<MetaConversionsResponse | undefined> {\n const capiEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .filter((event) => Date.now() - Date.parse(event.created_at) <= MAX_EVENT_AGE_MS)\n .map((event) => getCapiEvent(event, data, options.appPackageName));\n if (capiEvents.length === 0) return undefined;\n // \"The client_user_agent is required for website events\" — sent from `data.user_agent`.\n if (!data.user_agent && capiEvents.some((event) => event.action_source === 'website')) {\n console.warn(\n 'Meta conversion: website events sent without client_user_agent (data.user_agent)'\n );\n }\n\n const version = options.apiVersion ?? API_VERSION;\n try {\n const response = await fetch(`https://graph.facebook.com/${version}/${pixelId}/events`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n data: capiEvents,\n test_event_code: options.testEventCode,\n access_token: accessToken,\n }),\n });\n if (response.ok) return (await response.json()) as MetaConversionsResponse;\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Meta conversion, status: ${status}, body: ${message}`);\n return undefined;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Meta conversion, network error: ${message}`);\n return undefined;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TrackEvent, UserProvidedData } from "../track/types.cjs";
|
|
1
|
+
import { TrackEvent, TrackTags, UserProvidedData } from "../track/types.cjs";
|
|
2
2
|
//#region src/server/meta-capi.d.ts
|
|
3
3
|
interface CapiUserData {
|
|
4
4
|
em?: string[];
|
|
@@ -24,6 +24,19 @@ interface CapiUserData {
|
|
|
24
24
|
doby?: string;
|
|
25
25
|
madid?: string;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset
|
|
29
|
+
* indexes out when it serializes; the documented 16-element array is what the Graph endpoint
|
|
30
|
+
* decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS
|
|
31
|
+
* version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The `extinfo` version of an app event's OS — `i2` for iOS, `a2` for Android, the only two Meta
|
|
35
|
+
* documents. Undefined for any other OS (a desktop app): such an event cannot carry the
|
|
36
|
+
* `app_data` an app event requires, and is sent as `other`.
|
|
37
|
+
* https://developers.facebook.com/docs/marketing-api/conversions-api/app-events
|
|
38
|
+
*/
|
|
39
|
+
export declare function metaExtinfoVersion(tags: TrackTags): 'i2' | 'a2' | undefined;
|
|
27
40
|
/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */
|
|
28
41
|
export interface CapiEvent {
|
|
29
42
|
event_name: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-capi.d.cts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA
|
|
1
|
+
{"version":3,"file":"meta-capi.d.cts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;;;;;;;;wBAqKc,mBAAmB,MAAM;;iBAmCxB;EACf;EACA;EACA;EACA;EACA;EACA,WAAW;EACX,aAAa;EACb,WAAW;;;;;;;;wBASG,aAEd,OAAO,iBACP,MAAM,kBACN,0BACC;iBA+Bc;;EAEf;;EAEA;;EAEA;;iBAGe;EACf;EACA;EACA;;;;;;;wBAeoB,WACpB,qBACA,iBAEA,QAAQ,mBACR,OAAM,kBACN,UAAS,yBACR,QAAQ"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TrackEvent, UserProvidedData } from "../track/types.mjs";
|
|
1
|
+
import { TrackEvent, TrackTags, UserProvidedData } from "../track/types.mjs";
|
|
2
2
|
//#region src/server/meta-capi.d.ts
|
|
3
3
|
interface CapiUserData {
|
|
4
4
|
em?: string[];
|
|
@@ -24,6 +24,19 @@ interface CapiUserData {
|
|
|
24
24
|
doby?: string;
|
|
25
25
|
madid?: string;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* `extinfo` is keyed by field index. The business SDK builds the same object and leaves unset
|
|
29
|
+
* indexes out when it serializes; the documented 16-element array is what the Graph endpoint
|
|
30
|
+
* decodes this into. Indexes: 0 version, 1 package, 2 short version, 3 long version, 4 OS
|
|
31
|
+
* version, 5 device model, 6 locale, 9 screen width, 10 height, 11 density.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The `extinfo` version of an app event's OS — `i2` for iOS, `a2` for Android, the only two Meta
|
|
35
|
+
* documents. Undefined for any other OS (a desktop app): such an event cannot carry the
|
|
36
|
+
* `app_data` an app event requires, and is sent as `other`.
|
|
37
|
+
* https://developers.facebook.com/docs/marketing-api/conversions-api/app-events
|
|
38
|
+
*/
|
|
39
|
+
export declare function metaExtinfoVersion(tags: TrackTags): 'i2' | 'a2' | undefined;
|
|
27
40
|
/** One entry of the `data` array POSTed to `/{pixel_id}/events`. */
|
|
28
41
|
export interface CapiEvent {
|
|
29
42
|
event_name: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta-capi.d.mts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA
|
|
1
|
+
{"version":3,"file":"meta-capi.d.mts","names":[],"sources":["../../src/server/meta-capi.ts"],"mappings":";;UAsGU;EACR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;;;;;;;;;;;;wBAqKc,mBAAmB,MAAM;;iBAmCxB;EACf;EACA;EACA;EACA;EACA;EACA,WAAW;EACX,aAAa;EACb,WAAW;;;;;;;;wBASG,aAEd,OAAO,iBACP,MAAM,kBACN,0BACC;iBA+Bc;;EAEf;;EAEA;;EAEA;;iBAGe;EACf;EACA;EACA;;;;;;;wBAeoB,WACpB,qBACA,iBAEA,QAAQ,mBACR,OAAM,kBACN,UAAS,yBACR,QAAQ"}
|