Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/content/scripts/tiktok-pixel.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,61 @@

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
<script setup lang="ts">
const { proxy } = useScriptTikTokPixel({ id: 'YOUR_PIXEL_ID' })

async function checkout(order: { id: string, total: number }) {
const eventId = crypto.randomUUID()

proxy.ttq('track', 'Purchase', { value: order.total, currency: 'USD', order_id: order.id }, { event_id: eventId })

await $fetch('/api/tiktok/event', {
method: 'POST',
body: { event: 'Purchase', event_id: eventId, order_id: order.id, value: order.total },
})
}
</script>
```

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:

Check warning on line 117 in docs/content/scripts/tiktok-pixel.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "is detected". Consider rewriting in active voice

Check warning on line 117 in docs/content/scripts/tiktok-pixel.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "is detected". Consider rewriting in active voice

```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
::
5 changes: 3 additions & 2 deletions packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,11 +496,12 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
resolve(options?: TikTokPixelInput) {
if (!options?.id)
return false
return withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', { 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: {
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'] },
Expand Down
6 changes: 6 additions & 0 deletions packages/script/src/runtime/registry/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
48 changes: 47 additions & 1 deletion packages/script/src/runtime/registry/tiktok-pixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ type StandardEvents
| 'CompleteRegistration'
| 'Subscribe'
| 'StartTrial'
| 'ApplicationApproval'
| 'CustomizeProduct'
| 'FindLocation'
| 'Schedule'
| 'SubmitApplication'

interface EventProperties {
content_id?: string
Expand All @@ -30,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
}

Expand Down Expand Up @@ -75,6 +99,26 @@ export { TikTokPixelOptions }

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

/** 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<string, unknown>): 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
Expand All @@ -87,7 +131,7 @@ export interface TikTokPixelConsent {
export function useScriptTikTokPixel<T extends TikTokPixelApi>(_options?: TikTokPixelInput): UseScriptContext<T, TikTokPixelConsent> {
const instance = useRegistryScript<T, typeof TikTokPixelOptions>('tiktokPixel', options => ({
scriptInput: {
src: withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', {
src: withQuery(tiktokPixelSrc(options?.region), {
sdkid: options?.id,
lib: 'ttq',
}),
Expand All @@ -104,6 +148,8 @@ export function useScriptTikTokPixel<T extends TikTokPixelApi>(_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
Expand Down
8 changes: 7 additions & 1 deletion test/types/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -174,4 +174,10 @@ describe('tiktok pixel ttq', () => {
it('track still accepts arbitrary custom event names', () => {
expectTypeOf<Ttq>().toBeCallableWith('track', 'CustomEvent')
})

it('proxy.ttq preserves the track overload with event_id', () => {
type ProxyTtq = ReturnType<typeof useScriptTikTokPixel>['proxy']['ttq']
expectTypeOf<ProxyTtq>().toBeCallableWith('track', 'Purchase', { value: 10 }, { event_id: 'abc' })
expectTypeOf<ProxyTtq>().toBeCallableWith('track', 'StartTrial')
})
})
Loading