From 1ff2e6664afef23626ac7d6ac71a53d38aa2c39a Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Tue, 30 Jun 2026 09:57:13 +0530 Subject: [PATCH 1/7] feat(reddit-conversions-api): upgrade API to v3.0 behind feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add REDDIT_CONVERSIONS_CANARY_API_VERSION (v3.0) to versioning-info.ts - Implement feature flag 'reddit-conversions-api-canary-version' via getApiVersion(features) helper in utils.ts - Thread features through send() and both actions' perform/performBatch (standardEvent, customEvent) - testAuthentication intentionally remains on stable v2.0 - Update existing tests to use API_VERSION constant - Add stable + canary feature-flag tests for both actions - All 23 tests passing Breaking changes analysis in breaking-changes-analysis.md (changelog requires manual review — Reddit API host not reachable from build env) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.ts | 113 ++++++++++++++++-- .../breaking-changes-analysis.md | 77 ++++++++++++ .../customEvent/index.ts | 8 +- .../standardEvent/index.ts | 8 +- .../reddit-conversions-api/utils.ts | 33 +++-- .../reddit-conversions-api/versioning-info.ts | 9 +- 6 files changed, 217 insertions(+), 31 deletions(-) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts index eb97ea50d7a..0485d536e4e 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/reddit-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 { Settings } from '../generated-types' +import { API_VERSION, CANARY_API_VERSION, FLAGON_NAME } from '../utils' const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' @@ -42,7 +43,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('customEvent', { event, settings, @@ -125,7 +126,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('customEvent', { event, settings, @@ -211,7 +212,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -294,7 +295,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -373,7 +374,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -450,7 +451,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -529,7 +530,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -609,7 +610,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -691,7 +692,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -773,7 +774,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -855,7 +856,7 @@ describe('Reddit Conversions Api', () => { } }) - nock('https://ads-api.reddit.com').post('/api/v2.0/conversions/events/ad_account_id_1').reply(200, {}) + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) const responses = await testDestination.testAction('standardEvent', { event, settings, @@ -907,4 +908,94 @@ describe('Reddit Conversions Api', () => { }) }) }) + + describe('API Version Feature Flag', () => { + const flagEvent = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-flag', + type: 'track', + userId: 'user_id_1', + properties: { + click_id: 'click_id_1', + currency: 'USD', + revenue: 100, + email: 'test@test.com' + }, + context: { + userAgent: 'test-user-agent', + ip: '111.111.111.111' + } + }) + + describe('standardEvent', () => { + it('should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) + }) + + describe('customEvent', () => { + it('should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testAction('customEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .reply(200, {}) + + const responses = await testDestination.testAction('customEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md b/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md new file mode 100644 index 00000000000..5b4ab1491a0 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md @@ -0,0 +1,77 @@ +# Breaking Changes Analysis: v2.0 → v3.0 + +## Summary + +Upgrade of the Reddit Conversions API from **v2.0** (stable/production) to **v3.0** +(canary/feature-flagged). The version is the only path segment that changes in the +request URL: + +``` +https://ads-api.reddit.com/api//conversions/events/ +``` + +The new version is deployed behind the feature flag `reddit-conversions-api-canary-version`. +With the flag off, behavior is byte-for-byte identical to today. + +## ⚠️ Changelog Retrieval Status + +> **The Reddit changelog could not be fetched programmatically during this upgrade.** +> Both `https://ads-api.reddit.com/docs/v2/changelog` and `https://ads-api.reddit.com/docs/` +> returned network errors from the build environment. +> +> **Action required before merge / promoting the canary to stable:** a maintainer must +> manually review the v3.0 changelog at https://ads-api.reddit.com/docs/v2/changelog +> (and any v3 migration guide Reddit publishes) and complete the breaking-changes +> checklist below. Do not enable the feature flag for production traffic until this +> review is done. + +## Breaking Changes Checklist (to be completed via manual changelog review) + +### Request Changes + +- [ ] New required parameters +- [ ] Removed or deprecated parameters +- [ ] Changed parameter types or formats +- [ ] Modified validation rules +- [ ] Different authentication methods (currently `Authorization: Bearer `) +- [ ] New headers required +- [ ] Changed request body structure (`events[]`, `event_type`, `event_metadata`, `user`, etc.) + +### Response Changes + +- [ ] Modified response schema +- [ ] Removed response fields +- [ ] Changed field types +- [ ] Different error codes (current handling: 401 → invalid token, 403 → invalid ad account) +- [ ] New error response formats + +### Behavioral Changes + +- [ ] Rate limiting differences +- [ ] Batching size limits +- [ ] Timeout changes +- [ ] Retry logic requirements +- [ ] Idempotency key handling + +### Endpoint Changes + +- [ ] URL pattern changes (currently `/api//conversions/events/`) +- [ ] Method changes (currently POST) +- [ ] Deprecated endpoints +- [ ] New endpoints replacing old ones + +## Risk Assessment + +**Risk Level**: MEDIUM (until changelog review is completed) + +**Mitigation**: + +- Feature flag `reddit-conversions-api-canary-version` allows instant rollback. +- Stable v2.0 path is unchanged; canary only activates when the flag is set. +- Unit tests cover both stable (flag off) and canary (flag on) request URLs. + +## Testing Requirements + +- Verify stable v2.0 endpoint is called by default (no `features`). +- Verify v3.0 endpoint is called when `reddit-conversions-api-canary-version` is enabled. +- Cover both `standardEvent` and `customEvent` actions, single + batch. diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts index b470347be1c..37f6073e63e 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts @@ -28,11 +28,11 @@ const action: ActionDefinition = { event_metadata, conversion_id }, - perform: async (request, { settings, payload }) => { - return await send(request, settings, [payload]) + perform: async (request, { settings, payload, features }) => { + return await send(request, settings, [payload], features) }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + return await send(request, settings, payload, features) } } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts index a1209a791d4..76e6b71b2d0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts @@ -28,11 +28,11 @@ const action: ActionDefinition = { event_metadata, conversion_id }, - perform: async (request, { settings, payload }) => { - return await send(request, settings, [payload]) + perform: async (request, { settings, payload, features }) => { + return await send(request, settings, [payload], features) }, - performBatch: async (request, { settings, payload }) => { - return await send(request, settings, payload) + performBatch: async (request, { settings, payload, features }) => { + return await send(request, settings, payload, features) } } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts index 4a6e6f45ad1..e1cb0e4c5ac 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -1,4 +1,4 @@ -import type { RequestClient } from '@segment/actions-core' +import type { Features, RequestClient } from '@segment/actions-core' import type { Settings } from './generated-types' import type { Payload as StandardEvent } from './standardEvent/generated-types' import type { Payload as CustomEvent } from './customEvent/generated-types' @@ -11,7 +11,15 @@ import { DatapProcessingOptions } from './types' import { processHashing } from '../../lib/hashing-utils' -import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' +import { REDDIT_CONVERSIONS_API_VERSION, REDDIT_CONVERSIONS_CANARY_API_VERSION } from './versioning-info' + +export const API_VERSION = REDDIT_CONVERSIONS_API_VERSION +export const CANARY_API_VERSION = REDDIT_CONVERSIONS_CANARY_API_VERSION +export const FLAGON_NAME = 'reddit-conversions-api-canary-version' + +export function getApiVersion(features?: Features): string { + return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +} type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] type ProductsType = StandardEvent['products'] | CustomEvent['products'] @@ -20,16 +28,19 @@ type DataProcessingOptionsType = StandardEvent['data_processing_options'] | Cust type UserType = StandardEvent['user'] | CustomEvent['user'] type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['screen_dimensions'] -export async function send(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { +export async function send( + request: RequestClient, + settings: Settings, + payload: StandardEvent[] | CustomEvent[], + features?: Features +) { const data = createRedditPayload(payload, settings) - return request( - `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_API_VERSION}/conversions/events/${settings.ad_account_id}`, - { - method: 'POST', - headers: { Authorization: `Bearer ${settings.conversion_token}` }, - json: JSON.parse(JSON.stringify(data)) - } - ) + const version = getApiVersion(features) + return request(`https://ads-api.reddit.com/api/${version}/conversions/events/${settings.ad_account_id}`, { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + }) } function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings: Settings): StandardEventPayload { diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts index 8f6633b88ed..13fb757b1c2 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts @@ -1,5 +1,12 @@ /** REDDIT_CONVERSIONS_API_VERSION - * Reddit conversions API version. + * Reddit conversions API version (stable/production). * API reference: https://ads-api.reddit.com/docs/v2/changelog */ export const REDDIT_CONVERSIONS_API_VERSION = 'v2.0' + +/** REDDIT_CONVERSIONS_CANARY_API_VERSION + * Reddit conversions API version (canary/feature-flagged). + * Testing new version v3.0 behind feature flag. + * API reference: https://ads-api.reddit.com/docs/v2/changelog + */ +export const REDDIT_CONVERSIONS_CANARY_API_VERSION = 'v3.0' From 8385fbfb81fbbcd686725100b7efde0a5a0dfdb8 Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Tue, 30 Jun 2026 10:50:01 +0530 Subject: [PATCH 2/7] fix(reddit-conversions-api): correct canary version to v3 and complete changelog analysis - Path segment is 'v3' not 'v3.0' (changelog: /api/v2.0/ -> /api/v3/) - Complete breaking-changes-analysis.md from the v3 changelog: all Conversions API changes v2.0 -> v3 are additive; only the URL path version segment changes. Risk downgraded to LOW. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../breaking-changes-analysis.md | 124 ++++++++++++------ .../reddit-conversions-api/versioning-info.ts | 7 +- 2 files changed, 85 insertions(+), 46 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md b/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md index 5b4ab1491a0..a4e788582ce 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md @@ -1,77 +1,115 @@ -# Breaking Changes Analysis: v2.0 → v3.0 +# Breaking Changes Analysis: v2.0 → v3 + +Source: https://ads-api.reddit.com/docs/v3/changelog (reviewed manually) ## Summary -Upgrade of the Reddit Conversions API from **v2.0** (stable/production) to **v3.0** -(canary/feature-flagged). The version is the only path segment that changes in the -request URL: +Upgrade of the Reddit Conversions API from **v2.0** (stable/production) to **v3** +(canary/feature-flagged), behind the feature flag `reddit-conversions-api-canary-version`. + +The version is the only path segment that changes in the request URL: ``` https://ads-api.reddit.com/api//conversions/events/ ``` -The new version is deployed behind the feature flag `reddit-conversions-api-canary-version`. -With the flag off, behavior is byte-for-byte identical to today. +> **⚠️ Path segment is `v3`, NOT `v3.0`.** Per the changelog (Feb 6, 2024 — General +> Changes): _"Updated path from `/api/v2.0/` to `/api/v3/`."_ The canary constant is +> therefore `'v3'` so the URL resolves to `/api/v3/conversions/events/...`. + +For the **Conversions API endpoint specifically**, every change between v2.0 and v3 is +**additive or operational** — there are no changes that break the request body, response, +auth, or error handling this destination relies on. + +## Critical Breaking Changes (affecting this destination) + +**1. URL path version segment changed: `/api/v2.0/` → `/api/v3/`** — Risk: HIGH + +- **Impact**: The base path moves. This is the entire substance of the upgrade. +- **Required action**: Use `v3` (not `v3.0`) as the path segment. ✅ Done in `versioning-info.ts`. +- **Mitigation**: Behind feature flag; stable v2.0 path untouched. + +No other breaking changes affect the conversion events endpoint. + +## Conversions API changes v2.0 → v3 (all additive — no action required) + +| Date | Change | Type | +| ---------- | ------------------------------------------------------------------------------------------ | -------- | +| 2026-03-16 | Added `item_price` and `quantity` fields for products in conversion events | Additive | +| 2026-03-11 | Added `PHYSICAL_STORE`, `APP`, `OTHER` as valid action sources; added `conversion_metrics` | Additive | +| 2026-03-09 | Added `event_source_url` field for domain and click ID extraction | Additive | +| 2025-10-01 | Released Post Conversion Events (encouraged migration; new capability) | Additive | + +None of these remove or rename a field this destination sends today (`event_at`, +`event_type`, `click_id`, `event_metadata`, `user`, `products`, etc.), so the existing +payload remains valid against v3. + +## Operational / Behavioral Changes (platform-wide, not endpoint-breaking) + +- **Rate limiting** (2024-02-06 multi-level limits, user limit raised to 5 req/s; + 2025-07-21 group-based limits + `RateLimit` / `RateLimit-Policy` response headers). + - **Impact**: None on payload shape. Segment's request layer already handles 429s with + retry. No code change needed. +- **Removed account-level allow-listing requirement** (2024-02-06) — strictly a relaxation. -## ⚠️ Changelog Retrieval Status +## Explicitly NOT a breaking change for event sending -> **The Reddit changelog could not be fetched programmatically during this upgrade.** -> Both `https://ads-api.reddit.com/docs/v2/changelog` and `https://ads-api.reddit.com/docs/` -> returned network errors from the build environment. -> -> **Action required before merge / promoting the canary to stable:** a maintainer must -> manually review the v3.0 changelog at https://ads-api.reddit.com/docs/v2/changelog -> (and any v3 migration guide Reddit publishes) and complete the breaking-changes -> checklist below. Do not enable the feature flag for production traffic until this -> review is done. +- **2025-05-05** — _"Removed SEARCH, VIEW_CONTENT, and ADD_TO_WISHLIST support for + `optimization_goal` in new ad groups."_ This constrains **ad group `optimization_goal`** + configuration, **not** the conversion event `tracking_type`. This destination sends + `tracking_type` values (incl. `ViewContent`, `Search`, `AddToWishlist` via presets) on + conversion **events**, which is a different field on a different API. Sending those event + types remains valid. No change required. -## Breaking Changes Checklist (to be completed via manual changelog review) +## Checklist Verification (against v3 changelog) ### Request Changes -- [ ] New required parameters -- [ ] Removed or deprecated parameters -- [ ] Changed parameter types or formats -- [ ] Modified validation rules -- [ ] Different authentication methods (currently `Authorization: Bearer `) -- [ ] New headers required -- [ ] Changed request body structure (`events[]`, `event_type`, `event_metadata`, `user`, etc.) +- [x] New required parameters — **none** for conversions/events +- [x] Removed or deprecated parameters — **none** for conversions/events +- [x] Changed parameter types or formats — **none** +- [x] Modified validation rules — **none** for conversions/events +- [x] Different authentication methods — **no change** (`Authorization: Bearer `) +- [x] New headers required — **none** (new `RateLimit-*` headers are response-only) +- [x] Changed request body structure — **additive only** (`item_price`, `quantity`, `event_source_url`) ### Response Changes -- [ ] Modified response schema -- [ ] Removed response fields -- [ ] Changed field types -- [ ] Different error codes (current handling: 401 → invalid token, 403 → invalid ad account) -- [ ] New error response formats +- [x] Modified response schema — none affecting this destination +- [x] Removed response fields — none +- [x] Changed field types — none +- [x] Different error codes — none (401 → invalid token, 403 → invalid ad account still apply) +- [x] New error response formats — none ### Behavioral Changes -- [ ] Rate limiting differences -- [ ] Batching size limits -- [ ] Timeout changes -- [ ] Retry logic requirements -- [ ] Idempotency key handling +- [x] Rate limiting differences — yes (multi-level, 5 req/s, RateLimit headers); handled by request retry layer +- [x] Batching size limits — no documented change +- [x] Timeout changes — none +- [x] Retry logic requirements — none beyond existing 429 handling +- [x] Idempotency key handling — none ### Endpoint Changes -- [ ] URL pattern changes (currently `/api//conversions/events/`) -- [ ] Method changes (currently POST) -- [ ] Deprecated endpoints -- [ ] New endpoints replacing old ones +- [x] URL pattern changes — **YES: `/api/v2.0/` → `/api/v3/`** (handled) +- [x] Method changes — none (still POST) +- [x] Deprecated endpoints — conversions/events not deprecated +- [x] New endpoints replacing old ones — none for conversions/events ## Risk Assessment -**Risk Level**: MEDIUM (until changelog review is completed) +**Risk Level**: LOW **Mitigation**: - Feature flag `reddit-conversions-api-canary-version` allows instant rollback. - Stable v2.0 path is unchanged; canary only activates when the flag is set. -- Unit tests cover both stable (flag off) and canary (flag on) request URLs. +- All Conversions API changes from v2.0 → v3 are additive; existing payloads stay valid. +- Unit tests cover both stable (flag off → `v2.0`) and canary (flag on → `v3`) request URLs. ## Testing Requirements -- Verify stable v2.0 endpoint is called by default (no `features`). -- Verify v3.0 endpoint is called when `reddit-conversions-api-canary-version` is enabled. -- Cover both `standardEvent` and `customEvent` actions, single + batch. +- Verify stable `v2.0` endpoint is called by default (no `features`). ✅ +- Verify `v3` endpoint is called when `reddit-conversions-api-canary-version` is enabled. ✅ +- Cover both `standardEvent` and `customEvent` actions. ✅ +- Recommended: live smoke test against a Reddit test account with the flag on before promotion. diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts index 13fb757b1c2..fb1130c1130 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts @@ -6,7 +6,8 @@ export const REDDIT_CONVERSIONS_API_VERSION = 'v2.0' /** REDDIT_CONVERSIONS_CANARY_API_VERSION * Reddit conversions API version (canary/feature-flagged). - * Testing new version v3.0 behind feature flag. - * API reference: https://ads-api.reddit.com/docs/v2/changelog + * Testing new version v3 behind feature flag. Note the path segment is 'v3' + * (not 'v3.0') — per the changelog, the path moved from /api/v2.0/ to /api/v3/. + * API reference: https://ads-api.reddit.com/docs/v3/changelog */ -export const REDDIT_CONVERSIONS_CANARY_API_VERSION = 'v3.0' +export const REDDIT_CONVERSIONS_CANARY_API_VERSION = 'v3' From 945eeb337c6a1d201208102c9247e0afff62dee6 Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Tue, 30 Jun 2026 13:06:14 +0530 Subject: [PATCH 3/7] test(reddit-conversions-api): cover performBatch version selection Adds stable + canary feature-flag tests for the performBatch path on both standardEvent and customEvent, exercising the features-driven version selection threaded into send() (addresses PR review feedback). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts index 0485d536e4e..68ebe9949e8 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts @@ -961,6 +961,37 @@ describe('Reddit Conversions Api', () => { expect(responses[0].status).toBe(200) expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) }) + + it('performBatch should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testBatchAction('standardEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('performBatch should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .reply(200, {}) + + const responses = await testDestination.testBatchAction('standardEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) }) describe('customEvent', () => { @@ -996,6 +1027,37 @@ describe('Reddit Conversions Api', () => { expect(responses[0].status).toBe(200) expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) }) + + it('performBatch should use the stable API version by default', async () => { + nock('https://ads-api.reddit.com').post(`/api/${API_VERSION}/conversions/events/ad_account_id_1`).reply(200, {}) + + const responses = await testDestination.testBatchAction('customEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${API_VERSION}/`) + }) + + it('performBatch should use the canary API version when the feature flag is enabled', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .reply(200, {}) + + const responses = await testDestination.testBatchAction('customEvent', { + events: [flagEvent], + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'Some Custom Event Name' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) + }) }) }) }) From e902b0746baf50f6ce6b3cc74d5f36150ce3a685 Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Mon, 6 Jul 2026 23:46:02 +0530 Subject: [PATCH 4/7] feat(reddit-conversions-api): implement v3 payload behind feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Isolate v2 and v3 into separate modules with a single flag fork in utils.ts: - shared.ts: helpers reused by both (hashing, clean, getAdId, field types) - v2.ts: existing v2.0 payload + URL, moved verbatim (sendV2) - v3.ts: v3 payload (sendV3) — /pixels/{id}/conversion_events, { data: { events } } envelope, event_at as strict epoch-ms, event_type->type, event_metadata->metadata, value_decimal->value, UPPER_SNAKE tracking_type, action_source, event_source_url, test_id - utils.send() forks on the canary flag; actions unchanged New optional/defaulted fields (additive, only sent on v3): action_source (required, default WEBSITE), event_source_url, test_id, product quantity/item_price. Error handling relies on framework defaults (4xx no-retry except 408/423/429, 5xx retry), which already match Reddit's v3 error semantics. Tests: v2 regression suite unchanged; canary URL fixed to the v3 path; added v3 payload-shape tests (envelope, epoch-ms, UPPER_SNAKE, custom->CUSTOM, test_id). 30 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.ts | 83 +++- .../customEvent/generated-types.ts | 20 + .../customEvent/index.ts | 10 +- .../reddit-conversions-api/fields.ts | 48 ++- .../reddit-conversions-api/index.ts | 4 + .../reddit-conversions-api/metadata.json | 370 ++++++++++++++++++ .../reddit-conversions-api/shared.ts | 75 ++++ .../standardEvent/generated-types.ts | 20 + .../standardEvent/index.ts | 10 +- .../reddit-conversions-api/types.ts | 55 +++ .../reddit-conversions-api/utils.ts | 196 +--------- .../destinations/reddit-conversions-api/v2.ts | 144 +++++++ .../destinations/reddit-conversions-api/v3.ts | 167 ++++++++ 13 files changed, 1004 insertions(+), 198 deletions(-) create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/shared.ts create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/v2.ts create mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts index 68ebe9949e8..554555a2987 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts @@ -946,7 +946,7 @@ describe('Reddit Conversions Api', () => { it('should use the canary API version when the feature flag is enabled', async () => { nock('https://ads-api.reddit.com') - .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) .reply(200, {}) const responses = await testDestination.testAction('standardEvent', { @@ -978,7 +978,7 @@ describe('Reddit Conversions Api', () => { it('performBatch should use the canary API version when the feature flag is enabled', async () => { nock('https://ads-api.reddit.com') - .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) .reply(200, {}) const responses = await testDestination.testBatchAction('standardEvent', { @@ -1012,7 +1012,7 @@ describe('Reddit Conversions Api', () => { it('should use the canary API version when the feature flag is enabled', async () => { nock('https://ads-api.reddit.com') - .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) .reply(200, {}) const responses = await testDestination.testAction('customEvent', { @@ -1044,7 +1044,7 @@ describe('Reddit Conversions Api', () => { it('performBatch should use the canary API version when the feature flag is enabled', async () => { nock('https://ads-api.reddit.com') - .post(`/api/${CANARY_API_VERSION}/conversions/events/ad_account_id_1`) + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) .reply(200, {}) const responses = await testDestination.testBatchAction('customEvent', { @@ -1059,5 +1059,80 @@ describe('Reddit Conversions Api', () => { expect(responses[0].url).toContain(`/api/${CANARY_API_VERSION}/`) }) }) + + describe('v3 payload shape', () => { + it('wraps events in data, renames type/metadata, uses epoch-ms + UPPER_SNAKE + action_source', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase', action_source: 'WEBSITE', event_metadata: { value_decimal: 100 } }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + const body = responses[0].options.json as { + data: { partner: string; events: Record[] } + } + expect(body.data.partner).toBe('SEGMENT') + const event = body.data.events[0] as { + event_at: number + action_source: string + type: { tracking_type: string } + metadata: { value: number } + } + // epoch ms (timestamp = 2024-01-08T13:52:50.212Z) + expect(event.event_at).toBe(Date.parse(timestamp)) + expect(typeof event.event_at).toBe('number') + expect(event.action_source).toBe('WEBSITE') + expect(event.type.tracking_type).toBe('PURCHASE') + expect(event.metadata.value).toBe(100) + // v2-only keys must be gone + expect(event).not.toHaveProperty('event_type') + expect(event).not.toHaveProperty('event_metadata') + }) + + it('maps custom events to CUSTOM with custom_event_name inside type', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('customEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { custom_event_name: 'My Custom Event', action_source: 'WEBSITE' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + const body = responses[0].options.json as { data: { events: Record[] } } + const type = (body.data.events[0] as { type: { tracking_type: string; custom_event_name: string } }).type + expect(type.tracking_type).toBe('CUSTOM') + expect(type.custom_event_name).toBe('My Custom Event') + }) + + it('routes to Event Testing when test_id is set', async () => { + nock('https://ads-api.reddit.com') + .post(`/api/${CANARY_API_VERSION}/pixels/ad_account_id_1/conversion_events`) + .reply(200, {}) + + const responses = await testDestination.testAction('standardEvent', { + event: flagEvent, + settings, + useDefaultMappings: true, + mapping: { tracking_type: 'Purchase', action_source: 'WEBSITE', test_id: 'test-123' }, + features: { [FLAGON_NAME]: true } + }) + + expect(responses[0].status).toBe(200) + const body = responses[0].options.json as { data: { test_id?: string } } + expect(body.data.test_id).toBe('test-123') + }) + }) }) }) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts index 4c5db62be13..a8a6d1fd0cc 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts @@ -29,6 +29,14 @@ export interface Payload { * The name of the product. Optional. */ name?: string + /** + * The number of this product in the event. Only applies to Reddit Conversions API v3. + */ + quantity?: number + /** + * The unit price of the product. Only applies to Reddit Conversions API v3. + */ + item_price?: number }[] /** * The identifying user parameters associated with the conversion event. @@ -118,4 +126,16 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3. + */ + action_source: string + /** + * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3. + */ + event_source_url?: string + /** + * A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3. + */ + test_id?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts index 37f6073e63e..ed79bfa155c 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/index.ts @@ -11,7 +11,10 @@ import { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id } from '../fields' const action: ActionDefinition = { @@ -26,7 +29,10 @@ const action: ActionDefinition = { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id }, perform: async (request, { settings, payload, features }) => { return await send(request, settings, [payload], features) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts index 2d1e8cf9f28..45ec0771914 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -10,6 +10,38 @@ export const event_at: InputField = { } } +export const action_source: InputField = { + label: 'Action Source', + description: + 'The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.', + type: 'string', + required: true, + default: 'WEBSITE', + choices: [ + { label: 'Website', value: 'WEBSITE' }, + { label: 'App', value: 'APP' }, + { label: 'Offline (Physical Store)', value: 'PHYSICAL_STORE' }, + { label: 'Other', value: 'OTHER' } + ] +} + +export const event_source_url: InputField = { + label: 'Event Source URL', + description: + 'The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3.', + type: 'string', + required: false, + default: { '@path': '$.context.page.url' } +} + +export const test_id: InputField = { + label: 'Test ID', + description: + 'A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3.', + type: 'string', + required: false +} + export const custom_event_name: InputField = { label: 'Custom Event Name', description: @@ -292,6 +324,18 @@ export const products: InputField = { description: 'The name of the product. Optional.', type: 'string', required: false + }, + quantity: { + label: 'Quantity', + description: 'The number of this product in the event. Only applies to Reddit Conversions API v3.', + type: 'integer', + required: false + }, + item_price: { + label: 'Item Price', + description: 'The unit price of the product. Only applies to Reddit Conversions API v3.', + type: 'number', + required: false } }, default: { @@ -300,7 +344,9 @@ export const products: InputField = { { category: { '@path': '$.category' }, id: { '@path': '$.product_id' }, - name: { '@path': '$.name' } + name: { '@path': '$.name' }, + quantity: { '@path': '$.quantity' }, + item_price: { '@path': '$.price' } } ] } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts index 610b108d5d9..e7282fa4594 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -26,6 +26,10 @@ const destination: DestinationDefinition = { type: 'password', required: true }, + // NOTE (v3 migration): `test_mode` is a v2.0-only mechanism. Reddit CAPI v3 replaces it + // with a per-request `test_id` string (see V3-API-REFERENCE.md §5). This setting is only + // sent on the v2.0 path and should be deprecated/removed once v3 is promoted to stable + // and the feature flag is cleaned up. test_mode: { label: 'Test Mode', description: 'Indicates if events should be treated as test events by Reddit.', diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json index c8d2844593f..d1990f416c0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json @@ -190,6 +190,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -268,6 +274,54 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "quantity": { + "label": "Quantity", + "description": "The number of this product in the event. Only applies to Reddit Conversions API v3.", + "type": "integer", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "item_price": { + "label": "Item Price", + "description": "The unit price of the product. Only applies to Reddit Conversions API v3.", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } }, "category": null, @@ -2538,6 +2592,97 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "action_source": { + "label": "Action Source", + "description": "The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "WEBSITE", + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + }, + { + "label": "Other", + "value": "OTHER" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_source_url": { + "label": "Event Source URL", + "description": "The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.page.url" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "test_id": { + "label": "Test ID", + "description": "A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -2658,6 +2803,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -2736,6 +2887,54 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "quantity": { + "label": "Quantity", + "description": "The number of this product in the event. Only applies to Reddit Conversions API v3.", + "type": "integer", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "item_price": { + "label": "Item Price", + "description": "The unit price of the product. Only applies to Reddit Conversions API v3.", + "type": "number", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } }, "category": null, @@ -5006,6 +5205,97 @@ "displayMode": null, "format": null, "additionalProperties": false + }, + "action_source": { + "label": "Action Source", + "description": "The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.", + "type": "string", + "required": true, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": "WEBSITE", + "choices": [ + { + "label": "Website", + "value": "WEBSITE" + }, + { + "label": "App", + "value": "APP" + }, + { + "label": "Offline (Physical Store)", + "value": "PHYSICAL_STORE" + }, + { + "label": "Other", + "value": "OTHER" + } + ], + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "event_source_url": { + "label": "Event Source URL", + "description": "The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": { + "@path": "$.context.page.url" + }, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false + }, + "test_id": { + "label": "Test ID", + "description": "A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3.", + "type": "string", + "required": false, + "multiple": false, + "allowNull": false, + "dynamic": false, + "default": null, + "choices": null, + "placeholder": null, + "properties": null, + "category": null, + "depends_on": null, + "readOnly": null, + "hidden": null, + "minimum": null, + "maximum": null, + "defaultObjectUI": null, + "disabledInputMethods": null, + "displayMode": null, + "format": null, + "additionalProperties": false } } }, @@ -5065,6 +5355,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5147,6 +5443,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "PageVisit" }, "eventSlug": null @@ -5185,6 +5485,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5267,6 +5573,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "ViewContent" }, "eventSlug": null @@ -5305,6 +5615,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5387,6 +5703,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Search" }, "eventSlug": null @@ -5425,6 +5745,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5517,6 +5843,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToCart" }, "eventSlug": null @@ -5555,6 +5885,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5647,6 +5983,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "AddToWishlist" }, "eventSlug": null @@ -5685,6 +6025,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5787,6 +6133,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Purchase" }, "eventSlug": null @@ -5825,6 +6175,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -5914,6 +6270,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "Lead" }, "eventSlug": null @@ -5952,6 +6312,12 @@ }, "name": { "@path": "$.name" + }, + "quantity": { + "@path": "$.quantity" + }, + "item_price": { + "@path": "$.price" } } ] @@ -6041,6 +6407,10 @@ "conversion_id": { "@path": "$.messageId" }, + "action_source": "WEBSITE", + "event_source_url": { + "@path": "$.context.page.url" + }, "tracking_type": "SignUp" }, "eventSlug": null diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/shared.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/shared.ts new file mode 100644 index 00000000000..ca020ac08c6 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/shared.ts @@ -0,0 +1,75 @@ +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import type { Payload as CustomEvent } from './customEvent/generated-types' +import { processHashing } from '../../lib/hashing-utils' + +// Field types shared across both actions (standardEvent | customEvent). +export type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] +export type ProductsType = StandardEvent['products'] | CustomEvent['products'] +export type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] +export type DataProcessingOptionsType = + | StandardEvent['data_processing_options'] + | CustomEvent['data_processing_options'] +export type UserType = StandardEvent['user'] | CustomEvent['user'] +export type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['screen_dimensions'] + +export function clean(str: string | undefined): string | undefined { + if (str === undefined || str === null || str === '') return undefined + return str.trim() +} + +export function cleanNum(num: number | undefined): number | undefined { + if (num === undefined || num === null) return undefined + return num +} + +export function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { + if (height === undefined || width === undefined) return undefined + return { height, width } +} + +export function canonicalizeEmail(value: string): string { + value = value.trim() + const localPartAndDomain = value.split('@') + const localPart = localPartAndDomain[0].replace(/\./g, '').split('+')[0] + return `${localPart.toLowerCase()}@${localPartAndDomain[1].toLowerCase()}` +} + +export const smartHash = ( + value: string | undefined, + cleaningFunction?: (value: string) => string +): string | undefined => { + if (value === undefined) return + return processHashing(value, 'sha256', 'hex', cleaningFunction) +} + +export function cleanPhoneNumber(phoneNumber: string): string { + if (!phoneNumber) return '' + phoneNumber = phoneNumber.trim() + const prefix = '+' + if (phoneNumber.startsWith('+')) { + phoneNumber = phoneNumber.slice(1) + } + // Remove any potential extensions from the number + const extensions = ['ext', 'x', 'anexo', '#', 'poste', 'int'] + const lower = phoneNumber.toLowerCase() + for (const keyword of extensions) { + const index = lower.indexOf(keyword) + if (index !== -1) { + phoneNumber = phoneNumber.slice(0, index) + break + } + } + // Add the prefix and remove all non-numeric characters + const digitsOnly = phoneNumber.replace(/\D/g, '') + return prefix + digitsOnly +} + +export function getAdId( + device_type?: string, + advertising_id?: string +): { [key: string]: string | undefined } | undefined { + if (!device_type) return undefined + if (!advertising_id) return undefined + const hashedAdId = smartHash(advertising_id) + return device_type === 'ios' ? { idfa: hashedAdId } : { aaid: hashedAdId } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts index 82d3f61f89b..5cb46de09bf 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts @@ -29,6 +29,14 @@ export interface Payload { * The name of the product. Optional. */ name?: string + /** + * The number of this product in the event. Only applies to Reddit Conversions API v3. + */ + quantity?: number + /** + * The unit price of the product. Only applies to Reddit Conversions API v3. + */ + item_price?: number }[] /** * The identifying user parameters associated with the conversion event. @@ -118,4 +126,16 @@ export interface Payload { * The unique conversion ID that corresponds to a distinct conversion event. Use this for event deduplication. */ conversion_id?: string + /** + * The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3. + */ + action_source: string + /** + * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3. + */ + event_source_url?: string + /** + * A test ID from Reddit Event Testing. When set, events are routed to Event Testing for verification instead of production. Remove before sending production traffic. Only applies to Reddit Conversions API v3. + */ + test_id?: string } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts index 76e6b71b2d0..7583d551947 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/index.ts @@ -11,7 +11,10 @@ import { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id } from '../fields' const action: ActionDefinition = { @@ -26,7 +29,10 @@ const action: ActionDefinition = { data_processing_options, screen_dimensions, event_metadata, - conversion_id + conversion_id, + action_source, + event_source_url, + test_id }, perform: async (request, { settings, payload, features }) => { return await send(request, settings, [payload], features) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts index e9a6ca9f5f2..e3622736d62 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/types.ts @@ -61,3 +61,58 @@ export interface StandardEventPayload { test_mode?: boolean partner: 'SEGMENT' } + +// ---- v3 (feature-flagged) ---- + +export interface V3Product extends Product { + quantity?: number + item_price?: number +} + +export interface V3DataProcessingOptions { + country?: string + modes?: string[] + region?: string +} + +export interface V3User { + idfa?: string + aaid?: string + email?: string + external_id?: string + ip_address?: string + user_agent?: string + uuid?: string + data_processing_options?: V3DataProcessingOptions + screen_dimensions?: { height?: number; width?: number } + phone_number?: string +} + +export interface V3Metadata { + currency?: string + item_count?: number + value?: number + conversion_id?: string + products?: V3Product[] +} + +export interface V3EventItem { + event_at: number + action_source: string + event_source_url?: string + click_id?: string + type: { + tracking_type: string + custom_event_name?: string + } + metadata?: V3Metadata + user?: V3User +} + +export interface V3Payload { + data: { + events: V3EventItem[] + partner: 'SEGMENT' + test_id?: string + } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts index e1cb0e4c5ac..e245ebdcff7 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/utils.ts @@ -2,207 +2,25 @@ import type { Features, RequestClient } from '@segment/actions-core' import type { Settings } from './generated-types' import type { Payload as StandardEvent } from './standardEvent/generated-types' import type { Payload as CustomEvent } from './customEvent/generated-types' -import { - StandardEventPayloadItem, - StandardEventPayload, - User, - Product, - EventMetadata, - DatapProcessingOptions -} from './types' -import { processHashing } from '../../lib/hashing-utils' import { REDDIT_CONVERSIONS_API_VERSION, REDDIT_CONVERSIONS_CANARY_API_VERSION } from './versioning-info' +import { sendV2 } from './v2' +import { sendV3 } from './v3' export const API_VERSION = REDDIT_CONVERSIONS_API_VERSION export const CANARY_API_VERSION = REDDIT_CONVERSIONS_CANARY_API_VERSION export const FLAGON_NAME = 'reddit-conversions-api-canary-version' -export function getApiVersion(features?: Features): string { - return features && features[FLAGON_NAME] ? CANARY_API_VERSION : API_VERSION +export function isCanary(features?: Features): boolean { + return !!(features && features[FLAGON_NAME]) } -type EventMetadataType = StandardEvent['event_metadata'] | CustomEvent['event_metadata'] -type ProductsType = StandardEvent['products'] | CustomEvent['products'] -type ConversionIdType = StandardEvent['conversion_id'] | CustomEvent['conversion_id'] -type DataProcessingOptionsType = StandardEvent['data_processing_options'] | CustomEvent['data_processing_options'] -type UserType = StandardEvent['user'] | CustomEvent['user'] -type ScreenDimensionsType = StandardEvent['screen_dimensions'] | CustomEvent['screen_dimensions'] - +// Single flag fork: v3 (canary) or v2 (stable). Cleanup at flag removal = delete +// v2.ts, drop this branch, call sendV3 directly. export async function send( request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[], features?: Features ) { - const data = createRedditPayload(payload, settings) - const version = getApiVersion(features) - return request(`https://ads-api.reddit.com/api/${version}/conversions/events/${settings.ad_account_id}`, { - method: 'POST', - headers: { Authorization: `Bearer ${settings.conversion_token}` }, - json: JSON.parse(JSON.stringify(data)) - }) -} - -function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings: Settings): StandardEventPayload { - const payloadItems: StandardEventPayloadItem[] = [] - - payloads.forEach((payload) => { - const { - event_at, - click_id, - products, - user, - data_processing_options, - screen_dimensions, - event_metadata, - conversion_id - } = payload - - const custom_event_name = (payload as CustomEvent).custom_event_name - const tracking_type = (payload as StandardEvent).tracking_type - - const payloadItem: StandardEventPayloadItem = { - event_at: event_at as string, - event_type: { - // if custom_event_name is present, tracking_type is 'Custom' - // if custom_event_name not present then we know the event is a StandardEvent - tracking_type: custom_event_name ? 'Custom' : tracking_type, - custom_event_name: clean(custom_event_name) - }, - click_id: clean(click_id), - event_metadata: getMetadata(event_metadata, products, conversion_id), - user: getUser(user, data_processing_options, screen_dimensions) - } - - payloadItems.push(payloadItem) - }) - - return { - events: payloadItems, - test_mode: settings.test_mode, - partner: 'SEGMENT' - } -} - -function clean(str: string | undefined): string | undefined { - if (str === undefined || str === null || str === '') return undefined - return str.trim() -} - -function cleanNum(num: number | undefined): number | undefined { - if (num === undefined || num === null) return undefined - return num -} - -function getProducts(products: ProductsType): Product[] | undefined { - if (!products) { - return undefined - } - - return products.map((product) => { - return { - category: clean(product.category), - id: clean(product.id), - name: clean(product.name) - } - }) -} - -function getMetadata( - metadata: EventMetadataType, - products: ProductsType, - conversion_id: ConversionIdType -): EventMetadata | undefined { - if (!metadata && !products && !conversion_id) { - return undefined - } - - return { - currency: clean(metadata?.currency), - item_count: cleanNum(metadata?.item_count), - value_decimal: cleanNum(metadata?.value_decimal), - products: getProducts(products), - conversion_id: smartHash(conversion_id, (value) => value.trim()) - } -} - -function getAdId(device_type?: string, advertising_id?: string): { [key: string]: string | undefined } | undefined { - if (!device_type) return undefined - if (!advertising_id) return undefined - const hashedAdId = smartHash(advertising_id) - return device_type === 'ios' ? { idfa: hashedAdId } : { aaid: hashedAdId } -} - -function getDataProcessingOptions( - dataProcessingOptions: DataProcessingOptionsType -): DatapProcessingOptions | undefined { - if (!dataProcessingOptions) return undefined - return { - country: clean(dataProcessingOptions.country), - modes: dataProcessingOptions.modes?.split(',').map((mode) => mode.trim()), - region: clean(dataProcessingOptions.region) - } -} - -function getScreen(height?: number, width?: number): { height: number; width: number } | undefined { - if (height === undefined || width === undefined) return undefined - return { - height, - width - } -} - -function getUser( - user: UserType, - dataProcessingOptions: DataProcessingOptionsType, - screenDimensions: ScreenDimensionsType -): User | undefined { - if (!user) return - - return { - ...getAdId(user.device_type, user.advertising_id), - email: smartHash(user.email, canonicalizeEmail), - external_id: smartHash(user.external_id, (value) => value.trim()), - ip_address: smartHash(user.ip_address, (value) => value.trim()), - user_agent: clean(user.user_agent), - uuid: clean(user.uuid), - data_processing_options: getDataProcessingOptions(dataProcessingOptions), - screen_dimensions: getScreen(screenDimensions?.height, screenDimensions?.width), - phone_number: smartHash(user.phone_number, cleanPhoneNumber) - } -} - -function canonicalizeEmail(value: string): string { - value = value.trim() - const localPartAndDomain = value.split('@') - const localPart = localPartAndDomain[0].replace(/\./g, '').split('+')[0] - return `${localPart.toLowerCase()}@${localPartAndDomain[1].toLowerCase()}` -} - -const smartHash = (value: string | undefined, cleaningFunction?: (value: string) => string): string | undefined => { - if (value === undefined) return - return processHashing(value, 'sha256', 'hex', cleaningFunction) -} - -function cleanPhoneNumber(phoneNumber: string): string { - if (!phoneNumber) return '' - phoneNumber = phoneNumber.trim() - const prefix = '+' - if (phoneNumber.startsWith('+')) { - phoneNumber = phoneNumber.slice(1) - } - // Remove any potential extensions from the number - const extensions = ['ext', 'x', 'anexo', '#', 'poste', 'int'] - const lower = phoneNumber.toLowerCase() - for (const keyword of extensions) { - const index = lower.indexOf(keyword) - if (index !== -1) { - phoneNumber = phoneNumber.slice(0, index) - break - } - } - // Add the prefix and remove all non-numeric characters - const digitsOnly = phoneNumber.replace(/\D/g, '') - - return prefix + digitsOnly + return isCanary(features) ? sendV3(request, settings, payload) : sendV2(request, settings, payload) } diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v2.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v2.ts new file mode 100644 index 00000000000..699b9410474 --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v2.ts @@ -0,0 +1,144 @@ +import type { RequestClient } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import type { Payload as CustomEvent } from './customEvent/generated-types' +import { + StandardEventPayloadItem, + StandardEventPayload, + User, + Product, + EventMetadata, + DatapProcessingOptions +} from './types' +import { REDDIT_CONVERSIONS_API_VERSION } from './versioning-info' +import { + EventMetadataType, + ProductsType, + ConversionIdType, + DataProcessingOptionsType, + UserType, + ScreenDimensionsType, + clean, + cleanNum, + getScreen, + canonicalizeEmail, + smartHash, + cleanPhoneNumber, + getAdId +} from './shared' + +const V2_URL = (adAccountId: string) => + `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_API_VERSION}/conversions/events/${adAccountId}` + +export async function sendV2(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { + const data = createRedditPayload(payload, settings) + return request(V2_URL(settings.ad_account_id), { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + }) +} + +function createRedditPayload(payloads: StandardEvent[] | CustomEvent[], settings: Settings): StandardEventPayload { + const payloadItems: StandardEventPayloadItem[] = [] + + payloads.forEach((payload) => { + const { + event_at, + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id + } = payload + + const custom_event_name = (payload as CustomEvent).custom_event_name + const tracking_type = (payload as StandardEvent).tracking_type + + const payloadItem: StandardEventPayloadItem = { + event_at: event_at as string, + event_type: { + // if custom_event_name is present, tracking_type is 'Custom' + // if custom_event_name not present then we know the event is a StandardEvent + tracking_type: custom_event_name ? 'Custom' : tracking_type, + custom_event_name: clean(custom_event_name) + }, + click_id: clean(click_id), + event_metadata: getMetadata(event_metadata, products, conversion_id), + user: getUser(user, data_processing_options, screen_dimensions) + } + + payloadItems.push(payloadItem) + }) + + return { + events: payloadItems, + test_mode: settings.test_mode, + partner: 'SEGMENT' + } +} + +function getProducts(products: ProductsType): Product[] | undefined { + if (!products) { + return undefined + } + + return products.map((product) => { + return { + category: clean(product.category), + id: clean(product.id), + name: clean(product.name) + } + }) +} + +function getMetadata( + metadata: EventMetadataType, + products: ProductsType, + conversion_id: ConversionIdType +): EventMetadata | undefined { + if (!metadata && !products && !conversion_id) { + return undefined + } + + return { + currency: clean(metadata?.currency), + item_count: cleanNum(metadata?.item_count), + value_decimal: cleanNum(metadata?.value_decimal), + products: getProducts(products), + conversion_id: smartHash(conversion_id, (value) => value.trim()) + } +} + +function getDataProcessingOptions( + dataProcessingOptions: DataProcessingOptionsType +): DatapProcessingOptions | undefined { + if (!dataProcessingOptions) return undefined + return { + country: clean(dataProcessingOptions.country), + modes: dataProcessingOptions.modes?.split(',').map((mode) => mode.trim()), + region: clean(dataProcessingOptions.region) + } +} + +function getUser( + user: UserType, + dataProcessingOptions: DataProcessingOptionsType, + screenDimensions: ScreenDimensionsType +): User | undefined { + if (!user) return + + return { + ...getAdId(user.device_type, user.advertising_id), + email: smartHash(user.email, canonicalizeEmail), + external_id: smartHash(user.external_id, (value) => value.trim()), + ip_address: smartHash(user.ip_address, (value) => value.trim()), + user_agent: clean(user.user_agent), + uuid: clean(user.uuid), + data_processing_options: getDataProcessingOptions(dataProcessingOptions), + screen_dimensions: getScreen(screenDimensions?.height, screenDimensions?.width), + phone_number: smartHash(user.phone_number, cleanPhoneNumber) + } +} diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts new file mode 100644 index 00000000000..d27d26a239f --- /dev/null +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts @@ -0,0 +1,167 @@ +import type { RequestClient } from '@segment/actions-core' +import { PayloadValidationError } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload as StandardEvent } from './standardEvent/generated-types' +import type { Payload as CustomEvent } from './customEvent/generated-types' +import { REDDIT_CONVERSIONS_CANARY_API_VERSION } from './versioning-info' +import { + EventMetadataType, + ProductsType, + ConversionIdType, + DataProcessingOptionsType, + UserType, + ScreenDimensionsType, + clean, + cleanNum, + getScreen, + canonicalizeEmail, + smartHash, + cleanPhoneNumber, + getAdId +} from './shared' +import { V3EventItem, V3Payload, V3Metadata, V3Product, V3User, V3DataProcessingOptions } from './types' + +const V3_URL = (pixelId: string) => + `https://ads-api.reddit.com/api/${REDDIT_CONVERSIONS_CANARY_API_VERSION}/pixels/${pixelId}/conversion_events` + +// v2 tracking_type (mixed case) -> v3 UPPER_SNAKE_CASE. +const TRACKING_TYPE_V3: Record = { + PageVisit: 'PAGE_VISIT', + ViewContent: 'VIEW_CONTENT', + Search: 'SEARCH', + AddToCart: 'ADD_TO_CART', + AddToWishlist: 'ADD_TO_WISHLIST', + Purchase: 'PURCHASE', + Lead: 'LEAD', + SignUp: 'SIGN_UP', + Custom: 'CUSTOM' +} + +export async function sendV3(request: RequestClient, settings: Settings, payload: StandardEvent[] | CustomEvent[]) { + const data = createRedditPayloadV3(payload) + return request(V3_URL(settings.ad_account_id), { + method: 'POST', + headers: { Authorization: `Bearer ${settings.conversion_token}` }, + json: JSON.parse(JSON.stringify(data)) + }) +} + +function createRedditPayloadV3(payloads: StandardEvent[] | CustomEvent[]): V3Payload { + const test_id = clean(payloads[0]?.test_id) + + const events: V3EventItem[] = payloads.map((payload) => { + const { + event_at, + click_id, + products, + user, + data_processing_options, + screen_dimensions, + event_metadata, + conversion_id, + action_source, + event_source_url + } = payload + + const custom_event_name = clean((payload as CustomEvent).custom_event_name) + const tracking_type = custom_event_name ? 'Custom' : (payload as StandardEvent).tracking_type + + return { + event_at: toEpochMs(event_at), + action_source, + event_source_url: clean(event_source_url), + click_id: clean(click_id), + type: { + tracking_type: toV3TrackingType(tracking_type), + custom_event_name + }, + metadata: getMetadata(event_metadata, products, conversion_id), + user: getUser(user, data_processing_options, screen_dimensions) + } + }) + + return { data: { events, partner: 'SEGMENT', test_id } } +} + +// v3 requires event_at as an integer Unix epoch in milliseconds. We own the +// timestamp source (defaults to $.timestamp, an ISO string), so we accept ISO +// strings and 13-digit epoch-ms; anything else is rejected rather than sent wrong. +export function toEpochMs(value: string | number | undefined): number { + if (value === undefined || value === null || value === '') { + throw new PayloadValidationError('event_at is required') + } + // Already epoch milliseconds (number or 13-digit numeric string). + if (typeof value === 'number' && Number.isInteger(value)) return value + if (typeof value === 'string' && /^\d{13}$/.test(value.trim())) return Number(value.trim()) + // ISO 8601 / RFC3339 string. + if (typeof value === 'string') { + const ms = Date.parse(value) + if (!Number.isNaN(ms)) return ms + } + throw new PayloadValidationError( + `event_at must be an ISO 8601 timestamp or epoch milliseconds, received: ${String(value)}` + ) +} + +function toV3TrackingType(tracking_type: string | undefined): string { + if (!tracking_type) throw new PayloadValidationError('tracking_type is required') + const mapped = TRACKING_TYPE_V3[tracking_type] + if (!mapped) throw new PayloadValidationError(`Unsupported tracking_type: ${tracking_type}`) + return mapped +} + +function getProducts(products: ProductsType): V3Product[] | undefined { + if (!products) return undefined + return products.map((product) => ({ + category: clean(product.category), + id: clean(product.id), + name: clean(product.name), + quantity: cleanNum(product.quantity), + item_price: cleanNum(product.item_price) + })) +} + +function getMetadata( + metadata: EventMetadataType, + products: ProductsType, + conversion_id: ConversionIdType +): V3Metadata | undefined { + if (!metadata && !products && !conversion_id) return undefined + return { + currency: clean(metadata?.currency), + item_count: cleanNum(metadata?.item_count), + value: cleanNum(metadata?.value_decimal), + products: getProducts(products), + conversion_id: smartHash(conversion_id, (value) => value.trim()) + } +} + +function getDataProcessingOptions( + dataProcessingOptions: DataProcessingOptionsType +): V3DataProcessingOptions | undefined { + if (!dataProcessingOptions) return undefined + return { + country: clean(dataProcessingOptions.country), + modes: dataProcessingOptions.modes?.split(',').map((mode) => mode.trim()), + region: clean(dataProcessingOptions.region) + } +} + +function getUser( + user: UserType, + dataProcessingOptions: DataProcessingOptionsType, + screenDimensions: ScreenDimensionsType +): V3User | undefined { + if (!user) return + return { + ...getAdId(user.device_type, user.advertising_id), + email: smartHash(user.email, canonicalizeEmail), + external_id: smartHash(user.external_id, (value) => value.trim()), + ip_address: smartHash(user.ip_address, (value) => value.trim()), + user_agent: clean(user.user_agent), + uuid: clean(user.uuid), + data_processing_options: getDataProcessingOptions(dataProcessingOptions), + screen_dimensions: getScreen(screenDimensions?.height, screenDimensions?.width), + phone_number: smartHash(user.phone_number, cleanPhoneNumber) + } +} From d9a75463626d3e6a78be2781b8ea1bb583d33102 Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Mon, 13 Jul 2026 11:31:46 +0530 Subject: [PATCH 5/7] fix(reddit-conversions-api): make action_source optional to avoid breaking existing actions action_source is required by Reddit v3 but adding a required field to the existing standardEvent/customEvent actions is a breaking change (per CLAUDE.md) and would surface as a required-but-unused field for all v2 users. Make it optional with a WEBSITE default; enforce presence at runtime in the v3 payload builder instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reddit-conversions-api/customEvent/generated-types.ts | 2 +- .../src/destinations/reddit-conversions-api/fields.ts | 2 +- .../src/destinations/reddit-conversions-api/metadata.json | 4 ++-- .../reddit-conversions-api/standardEvent/generated-types.ts | 2 +- .../src/destinations/reddit-conversions-api/v3.ts | 2 ++ 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts index a8a6d1fd0cc..999ba5d8166 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/customEvent/generated-types.ts @@ -129,7 +129,7 @@ export interface Payload { /** * The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3. */ - action_source: string + action_source?: string /** * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3. */ diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts index 45ec0771914..d43d200cf1d 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/fields.ts @@ -15,7 +15,7 @@ export const action_source: InputField = { description: 'The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.', type: 'string', - required: true, + required: false, default: 'WEBSITE', choices: [ { label: 'Website', value: 'WEBSITE' }, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json index d1990f416c0..fe836bcefb0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/metadata.json @@ -2597,7 +2597,7 @@ "label": "Action Source", "description": "The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.", "type": "string", - "required": true, + "required": false, "multiple": false, "allowNull": false, "dynamic": false, @@ -5210,7 +5210,7 @@ "label": "Action Source", "description": "The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3.", "type": "string", - "required": true, + "required": false, "multiple": false, "allowNull": false, "dynamic": false, diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts index 5cb46de09bf..42e8f636f53 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/standardEvent/generated-types.ts @@ -129,7 +129,7 @@ export interface Payload { /** * The source/channel where the conversion occurred (used for omnichannel attribution). Only applies to Reddit Conversions API v3. */ - action_source: string + action_source?: string /** * The URL of the page where the event occurred. Reddit parses the domain for attribution. Include the click ID in the URL to improve match rates. Only applies to Reddit Conversions API v3. */ diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts index d27d26a239f..ed436bf5d8c 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts @@ -66,6 +66,8 @@ function createRedditPayloadV3(payloads: StandardEvent[] | CustomEvent[]): V3Pay const custom_event_name = clean((payload as CustomEvent).custom_event_name) const tracking_type = custom_event_name ? 'Custom' : (payload as StandardEvent).tracking_type + if (!action_source) throw new PayloadValidationError('action_source is required') + return { event_at: toEpochMs(event_at), action_source, From 4baddc97c88353434dc45dd4700ff756f09affec Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Mon, 20 Jul 2026 11:35:35 +0530 Subject: [PATCH 6/7] chore(reddit-conversions-api): remove stale breaking-changes doc, fix comments - Delete breaking-changes-analysis.md: written under the initial (wrong) assumption that v3 was only a URL version swap; v3 is a full payload rewrite, so the doc is inaccurate and misleading. - index.ts: drop dangling reference to a local-only doc in the test_mode comment. - versioning-info.ts: correct the canary comment to describe the real v3 endpoint/payload. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../breaking-changes-analysis.md | 115 ------------------ .../reddit-conversions-api/index.ts | 6 +- .../reddit-conversions-api/versioning-info.ts | 6 +- 3 files changed, 6 insertions(+), 121 deletions(-) delete mode 100644 packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md b/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md deleted file mode 100644 index a4e788582ce..00000000000 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/breaking-changes-analysis.md +++ /dev/null @@ -1,115 +0,0 @@ -# Breaking Changes Analysis: v2.0 → v3 - -Source: https://ads-api.reddit.com/docs/v3/changelog (reviewed manually) - -## Summary - -Upgrade of the Reddit Conversions API from **v2.0** (stable/production) to **v3** -(canary/feature-flagged), behind the feature flag `reddit-conversions-api-canary-version`. - -The version is the only path segment that changes in the request URL: - -``` -https://ads-api.reddit.com/api//conversions/events/ -``` - -> **⚠️ Path segment is `v3`, NOT `v3.0`.** Per the changelog (Feb 6, 2024 — General -> Changes): _"Updated path from `/api/v2.0/` to `/api/v3/`."_ The canary constant is -> therefore `'v3'` so the URL resolves to `/api/v3/conversions/events/...`. - -For the **Conversions API endpoint specifically**, every change between v2.0 and v3 is -**additive or operational** — there are no changes that break the request body, response, -auth, or error handling this destination relies on. - -## Critical Breaking Changes (affecting this destination) - -**1. URL path version segment changed: `/api/v2.0/` → `/api/v3/`** — Risk: HIGH - -- **Impact**: The base path moves. This is the entire substance of the upgrade. -- **Required action**: Use `v3` (not `v3.0`) as the path segment. ✅ Done in `versioning-info.ts`. -- **Mitigation**: Behind feature flag; stable v2.0 path untouched. - -No other breaking changes affect the conversion events endpoint. - -## Conversions API changes v2.0 → v3 (all additive — no action required) - -| Date | Change | Type | -| ---------- | ------------------------------------------------------------------------------------------ | -------- | -| 2026-03-16 | Added `item_price` and `quantity` fields for products in conversion events | Additive | -| 2026-03-11 | Added `PHYSICAL_STORE`, `APP`, `OTHER` as valid action sources; added `conversion_metrics` | Additive | -| 2026-03-09 | Added `event_source_url` field for domain and click ID extraction | Additive | -| 2025-10-01 | Released Post Conversion Events (encouraged migration; new capability) | Additive | - -None of these remove or rename a field this destination sends today (`event_at`, -`event_type`, `click_id`, `event_metadata`, `user`, `products`, etc.), so the existing -payload remains valid against v3. - -## Operational / Behavioral Changes (platform-wide, not endpoint-breaking) - -- **Rate limiting** (2024-02-06 multi-level limits, user limit raised to 5 req/s; - 2025-07-21 group-based limits + `RateLimit` / `RateLimit-Policy` response headers). - - **Impact**: None on payload shape. Segment's request layer already handles 429s with - retry. No code change needed. -- **Removed account-level allow-listing requirement** (2024-02-06) — strictly a relaxation. - -## Explicitly NOT a breaking change for event sending - -- **2025-05-05** — _"Removed SEARCH, VIEW_CONTENT, and ADD_TO_WISHLIST support for - `optimization_goal` in new ad groups."_ This constrains **ad group `optimization_goal`** - configuration, **not** the conversion event `tracking_type`. This destination sends - `tracking_type` values (incl. `ViewContent`, `Search`, `AddToWishlist` via presets) on - conversion **events**, which is a different field on a different API. Sending those event - types remains valid. No change required. - -## Checklist Verification (against v3 changelog) - -### Request Changes - -- [x] New required parameters — **none** for conversions/events -- [x] Removed or deprecated parameters — **none** for conversions/events -- [x] Changed parameter types or formats — **none** -- [x] Modified validation rules — **none** for conversions/events -- [x] Different authentication methods — **no change** (`Authorization: Bearer `) -- [x] New headers required — **none** (new `RateLimit-*` headers are response-only) -- [x] Changed request body structure — **additive only** (`item_price`, `quantity`, `event_source_url`) - -### Response Changes - -- [x] Modified response schema — none affecting this destination -- [x] Removed response fields — none -- [x] Changed field types — none -- [x] Different error codes — none (401 → invalid token, 403 → invalid ad account still apply) -- [x] New error response formats — none - -### Behavioral Changes - -- [x] Rate limiting differences — yes (multi-level, 5 req/s, RateLimit headers); handled by request retry layer -- [x] Batching size limits — no documented change -- [x] Timeout changes — none -- [x] Retry logic requirements — none beyond existing 429 handling -- [x] Idempotency key handling — none - -### Endpoint Changes - -- [x] URL pattern changes — **YES: `/api/v2.0/` → `/api/v3/`** (handled) -- [x] Method changes — none (still POST) -- [x] Deprecated endpoints — conversions/events not deprecated -- [x] New endpoints replacing old ones — none for conversions/events - -## Risk Assessment - -**Risk Level**: LOW - -**Mitigation**: - -- Feature flag `reddit-conversions-api-canary-version` allows instant rollback. -- Stable v2.0 path is unchanged; canary only activates when the flag is set. -- All Conversions API changes from v2.0 → v3 are additive; existing payloads stay valid. -- Unit tests cover both stable (flag off → `v2.0`) and canary (flag on → `v3`) request URLs. - -## Testing Requirements - -- Verify stable `v2.0` endpoint is called by default (no `features`). ✅ -- Verify `v3` endpoint is called when `reddit-conversions-api-canary-version` is enabled. ✅ -- Cover both `standardEvent` and `customEvent` actions. ✅ -- Recommended: live smoke test against a Reddit test account with the flag on before promotion. diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts index e7282fa4594..d90166a10b0 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/index.ts @@ -27,9 +27,9 @@ const destination: DestinationDefinition = { required: true }, // NOTE (v3 migration): `test_mode` is a v2.0-only mechanism. Reddit CAPI v3 replaces it - // with a per-request `test_id` string (see V3-API-REFERENCE.md §5). This setting is only - // sent on the v2.0 path and should be deprecated/removed once v3 is promoted to stable - // and the feature flag is cleaned up. + // with a per-request `test_id` field (see the customEvent/standardEvent action fields). + // This setting is only sent on the v2.0 path and should be deprecated/removed once v3 + // is promoted to stable and the feature flag is cleaned up. test_mode: { label: 'Test Mode', description: 'Indicates if events should be treated as test events by Reddit.', diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts index fb1130c1130..f53ab1e171e 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/versioning-info.ts @@ -6,8 +6,8 @@ export const REDDIT_CONVERSIONS_API_VERSION = 'v2.0' /** REDDIT_CONVERSIONS_CANARY_API_VERSION * Reddit conversions API version (canary/feature-flagged). - * Testing new version v3 behind feature flag. Note the path segment is 'v3' - * (not 'v3.0') — per the changelog, the path moved from /api/v2.0/ to /api/v3/. - * API reference: https://ads-api.reddit.com/docs/v3/changelog + * v3 is a breaking payload rewrite (new endpoint /api/v3/pixels/{pixel_id}/conversion_events, + * `data` envelope, renamed/retyped fields). See v3.ts for the transform. + * API reference: https://ads-api.reddit.com/docs/v3/capi-migration */ export const REDDIT_CONVERSIONS_CANARY_API_VERSION = 'v3' From 234648b113c729669116514c40499b8418213a84 Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Mon, 20 Jul 2026 18:01:01 +0530 Subject: [PATCH 7/7] fix(reddit-conversions-api): reject epoch-seconds in v3 event_at coercion toEpochMs accepted any integer as epoch-ms, so a 10-digit epoch-seconds value would be sent as ms (timestamp in 1970). Require numeric input to be plausibly ms (>= 1e12) else throw PayloadValidationError. Add unit tests for the rejection paths (seconds, non-integer, unparseable, missing) plus the accepted cases. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/index.test.ts | 30 +++++++++++++++++++ .../destinations/reddit-conversions-api/v3.ts | 15 ++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts index 554555a2987..5129a8ff310 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/__tests__/index.test.ts @@ -3,6 +3,7 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { Settings } from '../generated-types' import { API_VERSION, CANARY_API_VERSION, FLAGON_NAME } from '../utils' +import { toEpochMs } from '../v3' const testDestination = createTestIntegration(Definition) const timestamp = '2024-01-08T13:52:50.212Z' @@ -1134,5 +1135,34 @@ describe('Reddit Conversions Api', () => { expect(body.data.test_id).toBe('test-123') }) }) + + describe('toEpochMs (v3 event_at coercion)', () => { + it('accepts ISO 8601 strings', () => { + expect(toEpochMs('2024-01-08T13:52:50.212Z')).toBe(Date.parse('2024-01-08T13:52:50.212Z')) + }) + + it('accepts epoch milliseconds (number and 13-digit string)', () => { + expect(toEpochMs(1704721970212)).toBe(1704721970212) + expect(toEpochMs('1704721970212')).toBe(1704721970212) + }) + + it('rejects epoch seconds (10-digit) instead of silently misreading as ms', () => { + expect(() => toEpochMs(1704721970)).toThrow(/epoch milliseconds/) + expect(() => toEpochMs('1704721970')).toThrow(/epoch milliseconds/) + }) + + it('rejects non-integer numbers', () => { + expect(() => toEpochMs(1704721970212.5)).toThrow(/epoch milliseconds/) + }) + + it('rejects unparseable strings', () => { + expect(() => toEpochMs('not-a-date')).toThrow(/epoch milliseconds/) + }) + + it('rejects missing values', () => { + expect(() => toEpochMs(undefined)).toThrow(/required/) + expect(() => toEpochMs('')).toThrow(/required/) + }) + }) }) }) diff --git a/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts index ed436bf5d8c..87500773429 100644 --- a/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts +++ b/packages/destination-actions/src/destinations/reddit-conversions-api/v3.ts @@ -87,16 +87,21 @@ function createRedditPayloadV3(payloads: StandardEvent[] | CustomEvent[]): V3Pay // v3 requires event_at as an integer Unix epoch in milliseconds. We own the // timestamp source (defaults to $.timestamp, an ISO string), so we accept ISO -// strings and 13-digit epoch-ms; anything else is rejected rather than sent wrong. +// strings and epoch-ms; anything else is rejected rather than sent wrong. +// EPOCH_MS_MIN guards against epoch *seconds* being misread as ms (a 10-digit +// seconds value is < 1e12, so it's rejected instead of landing in 1970). +const EPOCH_MS_MIN = 1e12 export function toEpochMs(value: string | number | undefined): number { if (value === undefined || value === null || value === '') { throw new PayloadValidationError('event_at is required') } - // Already epoch milliseconds (number or 13-digit numeric string). - if (typeof value === 'number' && Number.isInteger(value)) return value - if (typeof value === 'string' && /^\d{13}$/.test(value.trim())) return Number(value.trim()) + // Already epoch milliseconds (number or numeric string), only if plausibly ms. + if (typeof value === 'number' && Number.isInteger(value) && value >= EPOCH_MS_MIN) return value + if (typeof value === 'string' && /^\d+$/.test(value.trim()) && Number(value.trim()) >= EPOCH_MS_MIN) { + return Number(value.trim()) + } // ISO 8601 / RFC3339 string. - if (typeof value === 'string') { + if (typeof value === 'string' && !/^\d+$/.test(value.trim())) { const ms = Date.parse(value) if (!Number.isNaN(ms)) return ms }