Skip to content

Commit c5b6905

Browse files
authored
feat(tiktokPixel): production hardening - region, CAPI dedup, advanced matching (#776)
1 parent f73a0ce commit c5b6905

5 files changed

Lines changed: 119 additions & 4 deletions

File tree

docs/content/scripts/tiktok-pixel.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,61 @@ function rejectAds() {
7070

7171
See the [TikTok cookie consent docs](https://business-api.tiktok.com/portal/docs?id=1739585600931842) for the full behaviour.
7272

73+
## Data Residency Region
74+
75+
Enterprises with US data-residency requirements can route the Pixel SDK through `analytics.us.tiktok.com` by setting `region: 'us'` (default `'global'`):
76+
77+
```ts
78+
useScriptTikTokPixel({
79+
id: 'YOUR_PIXEL_ID',
80+
region: 'us',
81+
})
82+
```
83+
84+
## Server-Side Event Deduplication
85+
86+
For the Pixel + Events API (CAPI) pattern, pass the same `event_id` on both the browser and server sides so TikTok deduplicates the pair:
87+
88+
```vue
89+
<script setup lang="ts">
90+
const { proxy } = useScriptTikTokPixel({ id: 'YOUR_PIXEL_ID' })
91+
92+
async function checkout(order: { id: string, total: number }) {
93+
const eventId = crypto.randomUUID()
94+
95+
proxy.ttq('track', 'Purchase', { value: order.total, currency: 'USD', order_id: order.id }, { event_id: eventId })
96+
97+
await $fetch('/api/tiktok/event', {
98+
method: 'POST',
99+
body: { event: 'Purchase', event_id: eventId, order_id: order.id, value: order.total },
100+
})
101+
}
102+
</script>
103+
```
104+
105+
See [TikTok's event-deduplication guide](https://ads.tiktok.com/help/article/event-deduplication?lang=en) for full rules.
106+
107+
## Test Events Sandbox
108+
109+
Set `test_event_code` on the 4th `track` argument to route an event into TikTok's Test Events panel without affecting production reporting:
110+
111+
```ts
112+
proxy.ttq('track', 'Purchase', { value: 99 }, { test_event_code: 'TEST12345' })
113+
```
114+
115+
## Advanced Matching
116+
117+
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:
118+
119+
```ts
120+
import { sha256 } from 'ohash'
121+
122+
const { proxy } = useScriptTikTokPixel({ id: 'YOUR_PIXEL_ID' })
123+
proxy.ttq('identify', {
124+
email: sha256('user@example.com'.trim().toLowerCase()),
125+
phone_number: sha256('+15551234567'),
126+
})
127+
```
128+
73129
::script-types
74130
::

packages/script/src/registry.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,11 +496,12 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
496496
resolve(options?: TikTokPixelInput) {
497497
if (!options?.id)
498498
return false
499-
return withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', { sdkid: options.id, lib: 'ttq' })
499+
const host = options.region === 'us' ? 'analytics.us.tiktok.com' : 'analytics.tiktok.com'
500+
return withQuery(`https://${host}/i18n/pixel/events.js`, { sdkid: options.id, lib: 'ttq' })
500501
},
501502
},
502503
proxy: {
503-
domains: ['analytics.tiktok.com', 'mon.tiktok.com', 'mcs.tiktok.com'],
504+
domains: ['analytics.tiktok.com', 'analytics.us.tiktok.com', 'mon.tiktok.com', 'mcs.tiktok.com'],
504505
privacy: PRIVACY_FULL,
505506
},
506507
partytown: { forwards: ['ttq.track', 'ttq.page', 'ttq.identify', 'ttq.grantConsent', 'ttq.revokeConsent', 'ttq.holdConsent'] },

packages/script/src/runtime/registry/schemas.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,12 @@ export const TikTokPixelOptions = object({
10491049
* @see https://business-api.tiktok.com/portal/docs?id=1739585600931842
10501050
*/
10511051
defaultConsent: optional(union([literal('granted'), literal('denied'), literal('hold')])),
1052+
/**
1053+
* Data residency region for the Pixel SDK.
1054+
* - `'global'` (default) -> `analytics.tiktok.com`
1055+
* - `'us'` -> `analytics.us.tiktok.com` (US enterprise data residency)
1056+
*/
1057+
region: optional(union([literal('global'), literal('us')])),
10521058
})
10531059

10541060
export const UmamiAnalyticsOptions = object({

packages/script/src/runtime/registry/tiktok-pixel.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ type StandardEvents
2020
| 'CompleteRegistration'
2121
| 'Subscribe'
2222
| 'StartTrial'
23+
| 'ApplicationApproval'
24+
| 'CustomizeProduct'
25+
| 'FindLocation'
26+
| 'Schedule'
27+
| 'SubmitApplication'
2328

2429
interface EventProperties {
2530
content_id?: string
@@ -30,18 +35,37 @@ interface EventProperties {
3035
value?: number
3136
description?: string
3237
query?: string
38+
/** Order/transaction identifier; complements `event_id` for transaction-level dedup. */
39+
order_id?: string
3340
[key: string]: any
3441
}
3542

43+
/**
44+
* Advanced matching parameters. TikTok requires SHA-256-hashed values for `email`,
45+
* `phone_number`, `external_id`, and the name/address fields to enable matching.
46+
* Passing raw values disables matching silently; a dev-mode warning is logged.
47+
* @see https://business-api.tiktok.com/portal/docs?id=1739585702922241
48+
*/
3649
interface IdentifyProperties {
3750
email?: string
3851
phone_number?: string
3952
external_id?: string
53+
first_name?: string
54+
last_name?: string
55+
city?: string
56+
state?: string
57+
country?: string
58+
zip_code?: string
4059
}
4160

4261
interface TrackOptions {
4362
/** Used to deduplicate events sent from both the browser Pixel and the server-side Events API. */
4463
event_id?: string
64+
/**
65+
* Sandbox test-event identifier. When set, events route to TikTok's Test Events panel
66+
* without affecting production reporting.
67+
*/
68+
test_event_code?: string
4569
[key: string]: any
4670
}
4771

@@ -75,6 +99,26 @@ export { TikTokPixelOptions }
7599

76100
export type TikTokPixelInput = RegistryScriptInput<typeof TikTokPixelOptions, true, false>
77101

102+
/** Resolve the Pixel SDK URL for a given data-residency region. */
103+
export function tiktokPixelSrc(region?: 'global' | 'us'): string {
104+
return region === 'us'
105+
? 'https://analytics.us.tiktok.com/i18n/pixel/events.js'
106+
: 'https://analytics.tiktok.com/i18n/pixel/events.js'
107+
}
108+
109+
const SHA256_HEX = /^[a-f0-9]{64}$/i
110+
111+
function warnUnhashedIdentify(props: Record<string, unknown>): void {
112+
const hashFields = ['email', 'phone_number', 'external_id', 'first_name', 'last_name', 'city', 'state', 'country', 'zip_code']
113+
const offenders = hashFields.filter((f) => {
114+
const v = props[f]
115+
return typeof v === 'string' && v.length > 0 && !SHA256_HEX.test(v)
116+
})
117+
if (offenders.length) {
118+
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`)
119+
}
120+
}
121+
78122
export interface TikTokPixelConsent {
79123
/** Call `ttq.grantConsent()`. */
80124
grant: () => void
@@ -87,7 +131,7 @@ export interface TikTokPixelConsent {
87131
export function useScriptTikTokPixel<T extends TikTokPixelApi>(_options?: TikTokPixelInput): UseScriptContext<T, TikTokPixelConsent> {
88132
const instance = useRegistryScript<T, typeof TikTokPixelOptions>('tiktokPixel', options => ({
89133
scriptInput: {
90-
src: withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', {
134+
src: withQuery(tiktokPixelSrc(options?.region), {
91135
sdkid: options?.id,
92136
lib: 'ttq',
93137
}),
@@ -104,6 +148,8 @@ export function useScriptTikTokPixel<T extends TikTokPixelApi>(_options?: TikTok
104148
: () => {
105149
window.TiktokAnalyticsObject = 'ttq'
106150
const ttq: TikTokPixelApi['ttq'] = window.ttq = function (...params: any[]) {
151+
if (import.meta.dev && params[0] === 'identify' && params[1])
152+
warnUnhashedIdentify(params[1])
107153
// @ts-expect-error untyped
108154
if (ttq.callMethod) {
109155
// @ts-expect-error untyped

test/types/types.test-d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ModuleOptions } from '../../packages/script/src/module'
22
import type { CrispApi } from '../../packages/script/src/runtime/registry/crisp'
33
import type { DefaultEventName } from '../../packages/script/src/runtime/registry/google-analytics'
4-
import type { TikTokPixelApi } from '../../packages/script/src/runtime/registry/tiktok-pixel'
4+
import type { TikTokPixelApi, useScriptTikTokPixel } from '../../packages/script/src/runtime/registry/tiktok-pixel'
55
import type { NuxtConfigScriptRegistry, NuxtConfigScriptRegistryEntry, NuxtUseScriptOptions, RegistryScriptInput, ScriptRegistry, UseScriptContext } from '../../packages/script/src/runtime/types'
66
import { describe, expectTypeOf, it } from 'vitest'
77

@@ -174,4 +174,10 @@ describe('tiktok pixel ttq', () => {
174174
it('track still accepts arbitrary custom event names', () => {
175175
expectTypeOf<Ttq>().toBeCallableWith('track', 'CustomEvent')
176176
})
177+
178+
it('proxy.ttq preserves the track overload with event_id', () => {
179+
type ProxyTtq = ReturnType<typeof useScriptTikTokPixel>['proxy']['ttq']
180+
expectTypeOf<ProxyTtq>().toBeCallableWith('track', 'Purchase', { value: 10 }, { event_id: 'abc' })
181+
expectTypeOf<ProxyTtq>().toBeCallableWith('track', 'StartTrial')
182+
})
177183
})

0 commit comments

Comments
 (0)