Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -838,4 +838,56 @@ describe('Snap Conversions API ', () => {
expect(user_data.sc_cookie1).toEqual((testEvent.integrations?.['Snap Conversions Api'] as any)?.uuid_c1)
})
})

describe('event_time normalization (snap-capi-event-time-in-seconds flag)', () => {
const FLAG = 'snap-capi-event-time-in-seconds'

it('emits milliseconds (13 digits) when the flag is OFF (default, unchanged behavior)', async () => {
const { data } = await reportConversionEvent({
mapping: { event_type: 'PURCHASE', event_conversion_type: 'WEB' }
})

// Date.parse('2022-05-12T15:21:15.449Z') === 1652368875449 (milliseconds)
expect(data.event_time).toEqual(1652368875449)
})

it('converts an ISO8601 timestamp to Unix seconds (10 digits) when the flag is ON', async () => {
const { data } = await reportConversionEvent({
features: { [FLAG]: true },
mapping: { event_type: 'PURCHASE', event_conversion_type: 'WEB' }
})

// 1652368875449 ms -> 1652368875 s
expect(data.event_time).toEqual(1652368875)
})

it('reproduces STRATCONN-6951: offline lead ms timestamp becomes valid seconds when ON', async () => {
const { data } = await reportConversionEvent({
event: { ...testEvent, timestamp: '2026-05-20T19:29:22.702Z' },
features: { [FLAG]: true },
mapping: { event_name: 'SIGN_UP', event_conversion_type: 'OFFLINE' }
})

// 1779305362702 ms (rejected by Snap as invalid) -> 1779305362 s (valid)
expect(data.event_time).toEqual(1779305362)
})

it('divides a numeric millisecond event_time (13 digits) to seconds when ON', async () => {
const { data } = await reportConversionEvent({
features: { [FLAG]: true },
mapping: { event_type: 'PURCHASE', event_conversion_type: 'WEB', event_time: '1652368875449' }
})

expect(data.event_time).toEqual(1652368875)
})

it('leaves a numeric second event_time (10 digits) untouched when ON', async () => {
const { data } = await reportConversionEvent({
features: { [FLAG]: true },
mapping: { event_type: 'PURCHASE', event_conversion_type: 'WEB', event_time: '1652368875' }
})

expect(data.event_time).toEqual(1652368875)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ExecuteInput, ModifiedResponse, RequestClient } from '@segment/actions-core'
import { ExecuteInput, Features, ModifiedResponse, RequestClient } from '@segment/actions-core'
import { Payload } from './generated-types'
import { Settings } from '../generated-types'
import {
Expand All @@ -11,11 +11,17 @@ import {
emptyStringToUndefined,
parseNumberSafe,
parseDateSafe,
normalizeToUnixSeconds,
smartHash
} from './utils'
import { processHashing } from '../../../lib/hashing-utils'
import { SNAP_CONVERSIONS_API_VERSION } from '../versioning-info'

// When enabled, `event_time` is normalized to a Unix timestamp in seconds (10 digits) as
// required by Snap's Conversions API, instead of the milliseconds (13 digits) produced by
// `Date.parse`. Gated for safe rollout on this high-volume destination.
export const FLAGON_EVENT_TIME_IN_SECONDS = 'snap-capi-event-time-in-seconds'

const CURRENCY_ISO_4217_CODES = new Set([
'USD',
'AED',
Expand Down Expand Up @@ -607,7 +613,7 @@ const getSupportedActionSource = (action_source: string | undefined): string | u
: undefined
}

const buildPayloadData = (payload: Payload, settings: Settings) => {
const buildPayloadData = (payload: Payload, settings: Settings, features?: Features) => {
// event_conversion_type is a required parameter whose value is enforced as
// always OFFLINE, WEB, or MOBILE_APP, so in practice action_source will always have a value.
const action_source =
Expand All @@ -624,7 +630,13 @@ const buildPayloadData = (payload: Payload, settings: Settings) => {
// Handle the case where a number is passed instead of an ISO8601 timestamp
const event_time_number = parseNumberSafe(payload_event_time ?? '')
const event_time_date_time = parseDateSafe(payload_event_time ?? '')
const event_time = event_time_date_time ?? event_time_number
const event_time_raw = event_time_date_time ?? event_time_number
// Snap's Conversions API expects `event_time` in seconds. `Date.parse` yields milliseconds,
// which Snap rejects as an invalid Unix timestamp. Normalize to seconds when the flag is on.
const event_time =
features?.[FLAGON_EVENT_TIME_IN_SECONDS] && event_time_raw != null
? normalizeToUnixSeconds(event_time_raw)
: event_time_raw

const app_data = action_source === 'app' ? buildAppData(payload, settings) : undefined
const user_data = buildUserData(payload)
Expand Down Expand Up @@ -733,9 +745,9 @@ export const performSnapCAPIv3 = async (
request: RequestClient,
data: ExecuteInput<Settings, Payload>
): Promise<ModifiedResponse<unknown>> => {
const { payload, settings } = data
const { payload, settings, features } = data

const payloadData = buildPayloadData(payload, settings)
const payloadData = buildPayloadData(payload, settings, features)

validatePayload(payloadData)
validateSettingsConfig(settings, payloadData.action_source)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ export const parseDateSafe = (v: string | undefined): number | undefined => {
return Number.isSafeInteger(parsed) ? parsed : undefined
}

// Snap's Conversions API expects `event_time` as a Unix timestamp in SECONDS (10 digits).
// `Date.parse` (and some upstream sources) produce MILLISECONDS (13 digits), which Snap's
// offline endpoint interprets as seconds far in the future and rejects with
// "Param data['event_time'] is an invalid Unix timestamp." Normalize milliseconds to seconds
// while leaving values already in seconds untouched.
//
// Any value >= 1e12 is treated as milliseconds: 1e12 ms is 2001-09-09, whereas a seconds
// timestamp does not reach 1e12 until the year 33658 — so modern seconds and milliseconds
// timestamps never overlap this threshold.
const MILLISECONDS_THRESHOLD = 1e12

export const normalizeToUnixSeconds = (timestamp: number): number =>
timestamp >= MILLISECONDS_THRESHOLD ? Math.floor(timestamp / 1000) : Math.floor(timestamp)

export const smartHash = (
value: string | undefined,
cleaningFunction?: (value: string) => string
Expand Down
Loading