From 867f8ba2521c7897aeb99f06ed2ffacc8331cd54 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 14 May 2026 15:19:16 +1000 Subject: [PATCH 1/4] test(tiktokPixel): verify proxy.ttq preserves track overload --- test/types/types.test-d.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 1789ffdc..88f02289 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -1,7 +1,7 @@ import type { ModuleOptions } from '../../packages/script/src/module' import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp' import type { DefaultEventName } from '../../packages/script/src/runtime/registry/google-analytics' -import type { TikTokPixelApi } from '../../packages/script/src/runtime/registry/tiktok-pixel' +import type { TikTokPixelApi, useScriptTikTokPixel } from '../../packages/script/src/runtime/registry/tiktok-pixel' import type { NuxtConfigScriptRegistry, NuxtConfigScriptRegistryEntry, NuxtUseScriptOptions, RegistryScriptInput, ScriptRegistry, UseScriptContext } from '../../packages/script/src/runtime/types' import { describe, expectTypeOf, it } from 'vitest' @@ -174,4 +174,10 @@ describe('tiktok pixel ttq', () => { it('track still accepts arbitrary custom event names', () => { expectTypeOf().toBeCallableWith('track', 'CustomEvent') }) + + it('proxy.ttq preserves the track overload with event_id', () => { + type ProxyTtq = ReturnType['proxy']['ttq'] + expectTypeOf().toBeCallableWith('track', 'Purchase', { value: 10 }, { event_id: 'abc' }) + expectTypeOf().toBeCallableWith('track', 'StartTrial') + }) }) From 4b502c3518ccc98c3b386bc4e86ccffb80ad6bab Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 14 May 2026 15:27:17 +1000 Subject: [PATCH 2/4] feat(tiktokPixel): add remaining canonical standard events --- packages/script/src/runtime/registry/tiktok-pixel.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/script/src/runtime/registry/tiktok-pixel.ts b/packages/script/src/runtime/registry/tiktok-pixel.ts index 4f97c383..0509cce2 100644 --- a/packages/script/src/runtime/registry/tiktok-pixel.ts +++ b/packages/script/src/runtime/registry/tiktok-pixel.ts @@ -20,6 +20,11 @@ type StandardEvents | 'CompleteRegistration' | 'Subscribe' | 'StartTrial' + | 'ApplicationApproval' + | 'CustomizeProduct' + | 'FindLocation' + | 'Schedule' + | 'SubmitApplication' interface EventProperties { content_id?: string From 2d668471287ba9fb260199043ed9004e94c3f93c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 14 May 2026 15:36:59 +1000 Subject: [PATCH 3/4] feat(tiktokPixel): production hardening - region, CAPI dedup, advanced matching - region: 'us' option routes SDK through analytics.us.tiktok.com for data residency - TrackOptions: add test_event_code for Test Events sandbox - EventProperties: add order_id (transaction-level dedup) - IdentifyProperties: add first_name/last_name/city/state/country/zip_code - dev warning when identify() receives unhashed values (SHA-256 required) - docs: server-side dedup, region, test events, advanced matching sections --- docs/content/scripts/tiktok-pixel.md | 56 +++++++++++++++++++ packages/script/src/registry.ts | 5 +- .../script/src/runtime/registry/schemas.ts | 6 ++ .../src/runtime/registry/tiktok-pixel.ts | 43 +++++++++++++- 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/docs/content/scripts/tiktok-pixel.md b/docs/content/scripts/tiktok-pixel.md index d35513ec..49b459ad 100644 --- a/docs/content/scripts/tiktok-pixel.md +++ b/docs/content/scripts/tiktok-pixel.md @@ -70,5 +70,61 @@ function rejectAds() { See the [TikTok cookie consent docs](https://business-api.tiktok.com/portal/docs?id=1739585600931842) for the full behaviour. +## Data Residency Region + +Enterprises with US data-residency requirements can route the Pixel SDK through `analytics.us.tiktok.com` by setting `region: 'us'` (default `'global'`): + +```ts +useScriptTikTokPixel({ + id: 'YOUR_PIXEL_ID', + region: 'us', +}) +``` + +## Server-Side Event Deduplication + +For the Pixel + Events API (CAPI) pattern, pass the same `event_id` on both the browser and server sides so TikTok deduplicates the pair: + +```vue + +``` + +See [TikTok's event-deduplication guide](https://ads.tiktok.com/help/article/event-deduplication?lang=en) for full rules. + +## Test Events Sandbox + +Set `test_event_code` on the 4th `track` argument to route an event into TikTok's Test Events panel without affecting production reporting: + +```ts +proxy.ttq('track', 'Purchase', { value: 99 }, { test_event_code: 'TEST12345' }) +``` + +## Advanced Matching + +TikTok requires identify fields (`email`, `phone_number`, `external_id`, `first_name`, `last_name`, `city`, `state`, `country`, `zip_code`) to be SHA-256-hashed lowercase. Raw values are silently ignored by TikTok; in development, Nuxt Scripts logs a warning when an unhashed value is detected: + +```ts +import { sha256 } from 'ohash' + +const { proxy } = useScriptTikTokPixel({ id: 'YOUR_PIXEL_ID' }) +proxy.ttq('identify', { + email: sha256('user@example.com'.trim().toLowerCase()), + phone_number: sha256('+15551234567'), +}) +``` + ::script-types :: diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index fc9b22c9..02339ed6 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -50,6 +50,7 @@ import { XEmbedOptions, XPixelOptions, } from './runtime/registry/schemas' +import { tiktokPixelSrc } from './runtime/registry/tiktok-pixel' export type { ScriptCapabilities } from './runtime/types' @@ -496,11 +497,11 @@ export async function registry(resolve?: (path: string) => Promise): Pro resolve(options?: TikTokPixelInput) { if (!options?.id) return false - return withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', { sdkid: options.id, lib: 'ttq' }) + return withQuery(tiktokPixelSrc(options.region), { sdkid: options.id, lib: 'ttq' }) }, }, proxy: { - domains: ['analytics.tiktok.com', 'mon.tiktok.com', 'mcs.tiktok.com'], + domains: ['analytics.tiktok.com', 'analytics.us.tiktok.com', 'mon.tiktok.com', 'mcs.tiktok.com'], privacy: PRIVACY_FULL, }, partytown: { forwards: ['ttq.track', 'ttq.page', 'ttq.identify', 'ttq.grantConsent', 'ttq.revokeConsent', 'ttq.holdConsent'] }, diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 64b7a5c4..d6be8661 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1049,6 +1049,12 @@ export const TikTokPixelOptions = object({ * @see https://business-api.tiktok.com/portal/docs?id=1739585600931842 */ defaultConsent: optional(union([literal('granted'), literal('denied'), literal('hold')])), + /** + * Data residency region for the Pixel SDK. + * - `'global'` (default) -> `analytics.tiktok.com` + * - `'us'` -> `analytics.us.tiktok.com` (US enterprise data residency) + */ + region: optional(union([literal('global'), literal('us')])), }) export const UmamiAnalyticsOptions = object({ diff --git a/packages/script/src/runtime/registry/tiktok-pixel.ts b/packages/script/src/runtime/registry/tiktok-pixel.ts index 0509cce2..3b413a64 100644 --- a/packages/script/src/runtime/registry/tiktok-pixel.ts +++ b/packages/script/src/runtime/registry/tiktok-pixel.ts @@ -35,18 +35,37 @@ interface EventProperties { value?: number description?: string query?: string + /** Order/transaction identifier; complements `event_id` for transaction-level dedup. */ + order_id?: string [key: string]: any } +/** + * Advanced matching parameters. TikTok requires SHA-256-hashed values for `email`, + * `phone_number`, `external_id`, and the name/address fields to enable matching. + * Passing raw values disables matching silently; a dev-mode warning is logged. + * @see https://business-api.tiktok.com/portal/docs?id=1739585702922241 + */ interface IdentifyProperties { email?: string phone_number?: string external_id?: string + first_name?: string + last_name?: string + city?: string + state?: string + country?: string + zip_code?: string } interface TrackOptions { /** Used to deduplicate events sent from both the browser Pixel and the server-side Events API. */ event_id?: string + /** + * Sandbox test-event identifier. When set, events route to TikTok's Test Events panel + * without affecting production reporting. + */ + test_event_code?: string [key: string]: any } @@ -80,6 +99,26 @@ export { TikTokPixelOptions } export type TikTokPixelInput = RegistryScriptInput +/** Resolve the Pixel SDK URL for a given data-residency region. */ +export function tiktokPixelSrc(region?: 'global' | 'us'): string { + return region === 'us' + ? 'https://analytics.us.tiktok.com/i18n/pixel/events.js' + : 'https://analytics.tiktok.com/i18n/pixel/events.js' +} + +const SHA256_HEX = /^[a-f0-9]{64}$/i + +function warnUnhashedIdentify(props: Record): void { + const hashFields = ['email', 'phone_number', 'external_id', 'first_name', 'last_name', 'city', 'state', 'country', 'zip_code'] + const offenders = hashFields.filter((f) => { + const v = props[f] + return typeof v === 'string' && v.length > 0 && !SHA256_HEX.test(v) + }) + if (offenders.length) { + console.warn(`[nuxt-scripts:tiktokPixel] identify() received unhashed value(s) for ${offenders.join(', ')}. TikTok requires SHA-256 hashing for advanced matching; raw values will be ignored. See https://business-api.tiktok.com/portal/docs?id=1739585702922241`) + } +} + export interface TikTokPixelConsent { /** Call `ttq.grantConsent()`. */ grant: () => void @@ -92,7 +131,7 @@ export interface TikTokPixelConsent { export function useScriptTikTokPixel(_options?: TikTokPixelInput): UseScriptContext { const instance = useRegistryScript('tiktokPixel', options => ({ scriptInput: { - src: withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', { + src: withQuery(tiktokPixelSrc(options?.region), { sdkid: options?.id, lib: 'ttq', }), @@ -109,6 +148,8 @@ export function useScriptTikTokPixel(_options?: TikTok : () => { window.TiktokAnalyticsObject = 'ttq' const ttq: TikTokPixelApi['ttq'] = window.ttq = function (...params: any[]) { + if (import.meta.dev && params[0] === 'identify' && params[1]) + warnUnhashedIdentify(params[1]) // @ts-expect-error untyped if (ttq.callMethod) { // @ts-expect-error untyped From a5e837d8169cad8cef3d755949612558c376e9ea Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 14 May 2026 15:52:22 +1000 Subject: [PATCH 4/4] fix(tiktokPixel): inline region URL in registry.ts to avoid runtime import --- packages/script/src/registry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 02339ed6..376e1d7a 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -50,7 +50,6 @@ import { XEmbedOptions, XPixelOptions, } from './runtime/registry/schemas' -import { tiktokPixelSrc } from './runtime/registry/tiktok-pixel' export type { ScriptCapabilities } from './runtime/types' @@ -497,7 +496,8 @@ export async function registry(resolve?: (path: string) => Promise): Pro resolve(options?: TikTokPixelInput) { if (!options?.id) return false - return withQuery(tiktokPixelSrc(options.region), { sdkid: options.id, lib: 'ttq' }) + const host = options.region === 'us' ? 'analytics.us.tiktok.com' : 'analytics.tiktok.com' + return withQuery(`https://${host}/i18n/pixel/events.js`, { sdkid: options.id, lib: 'ttq' }) }, }, proxy: {