diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts b/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts index fd49b81b6d5..a5fce9329e1 100644 --- a/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts +++ b/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts @@ -2,6 +2,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { smartHash } from '../reportConversionEvent/utils' +import { FLAGON_EVENT_TIME_IN_SECONDS } from '../reportConversionEvent/snap-capi-v3' const testDestination = createTestIntegration(Definition) @@ -838,4 +839,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 = FLAGON_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) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/snap-capi-v3.ts b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/snap-capi-v3.ts index 48cfc322b78..c8d8dea1a6d 100644 --- a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/snap-capi-v3.ts +++ b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/snap-capi-v3.ts @@ -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 { @@ -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', @@ -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 = @@ -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) @@ -733,9 +745,9 @@ export const performSnapCAPIv3 = async ( request: RequestClient, data: ExecuteInput ): Promise> => { - 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) diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/utils.ts b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/utils.ts index 4a48acce3e5..dbdb44ab85f 100644 --- a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/utils.ts +++ b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/utils.ts @@ -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 (e.g. 1777819609). +// `Date.parse` (and some upstream sources) produce milliseconds (e.g. 1777819609520), 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