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
returnfalse
499-
returnwithQuery('https://analytics.tiktok.com/i18n/pixel/events.js',{sdkid: options.id,lib: 'ttq'})
499+
consthost=options.region==='us' ? 'analytics.us.tiktok.com' : 'analytics.tiktok.com'
500+
returnwithQuery(`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
exportconstUmamiAnalyticsOptions=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
interfaceEventProperties{
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
interfaceIdentifyProperties{
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
interfaceTrackOptions{
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
exporttypeTikTokPixelInput=RegistryScriptInput<typeofTikTokPixelOptions,true,false>
77101

102+
/** Resolve the Pixel SDK URL for a given data-residency region. */
103+
exportfunctiontiktokPixelSrc(region?: 'global'|'us'): string{
104+
returnregion==='us'
105+
? 'https://analytics.us.tiktok.com/i18n/pixel/events.js'
106+
: 'https://analytics.tiktok.com/i18n/pixel/events.js'
107+
}
108+
109+
constSHA256_HEX=/^[a-f0-9]{64}$/i
110+
111+
functionwarnUnhashedIdentify(props: Record<string,unknown>): void{
112+
consthashFields=['email','phone_number','external_id','first_name','last_name','city','state','country','zip_code']
113+
constoffenders=hashFields.filter((f)=>{
114+
constv=props[f]
115+
returntypeofv==='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
exportinterfaceTikTokPixelConsent{
79123
/** Call `ttq.grantConsent()`. */
80124
grant: ()=>void
@@ -87,7 +131,7 @@ export interface TikTokPixelConsent {
87131
exportfunctionuseScriptTikTokPixel<TextendsTikTokPixelApi>(_options?: TikTokPixelInput): UseScriptContext<T,TikTokPixelConsent>{
88132
constinstance=useRegistryScript<T,typeofTikTokPixelOptions>('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
constttq: 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
importtype{ModuleOptions}from'../../packages/script/src/module'
22
importtype{CrispApi}from'../../packages/script/src/runtime/registry/crisp'
33
importtype{DefaultEventName}from'../../packages/script/src/runtime/registry/google-analytics'
4-
importtype{TikTokPixelApi}from'../../packages/script/src/runtime/registry/tiktok-pixel'
4+
importtype{TikTokPixelApi,useScriptTikTokPixel}from'../../packages/script/src/runtime/registry/tiktok-pixel'
55
importtype{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+
typeProxyTtq=ReturnType<typeofuseScriptTikTokPixel>['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)