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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { RequestClient, ModifiedResponse } from '@segment/actions-core'
import type { RequestClient, ModifiedResponse, Features } from '@segment/actions-core'

import type { Settings } from './generated-types'
import type { Payload } from './updateAudience/generated-types'
import { BASE_URL, LINKEDIN_API_VERSION, LINKEDIN_SOURCE_PLATFORM } from './constants'
import { BASE_URL, LINKEDIN_SOURCE_PLATFORM, getApiVersion } from './constants'
import type { ProfileAPIResponse, AdAccountUserResponse, LinkedInAudiencePayload } from './types'

export class LinkedInAudiences {
Expand Down Expand Up @@ -57,12 +57,16 @@ export class LinkedInAudiences {
})
}

async batchUpdate(dmpSegmentId: string, elements: LinkedInAudiencePayload[]): Promise<ModifiedResponse> {
async batchUpdate(
dmpSegmentId: string,
elements: LinkedInAudiencePayload[],
features?: Features
): Promise<ModifiedResponse> {
return this.request(`${BASE_URL}/dmpSegments/${dmpSegmentId}/users`, {
method: 'POST',
headers: {
'X-RestLi-Method': 'BATCH_CREATE',
'Linkedin-Version': LINKEDIN_API_VERSION // https://learn.microsoft.com/en-us/linkedin/marketing/matched-audiences/create-and-manage-segment-users?view=li-lms-2025-11&tabs=curl
'Linkedin-Version': getApiVersion(features) // https://learn.microsoft.com/en-us/linkedin/marketing/matched-audiences/create-and-manage-segment-users?view=li-lms-2025-11&tabs=curl
},
Comment on lines 67 to 70
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extendRequest already injects the LinkedIn-Version header for all requests. Setting a per-request version header here (with a different key casing) overrides the default and makes it easier for the versioning behavior to drift between endpoints. Consider relying on extendRequest for the version header (keep only X-RestLi-Method here), or at least standardize the header key casing to a single form across the destination.

Copilot uses AI. Check for mistakes.
json: {
elements
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Breaking Changes Analysis: LinkedIn Audiences API 202505 → 202603

## Summary

No new DMP Segments API-specific breaking changes were introduced in the 202505–202603 window. The upgrade is primarily driven by **version sunset urgency**: 202505 sunsets on **May 15, 2026** (~1 month from today). Upgrading to 202603 (supported until March 16, 2027) ensures continuity of service.
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phrase "(~1 month from today)" will become stale over time and can make this analysis misleading. Consider removing the relative time or anchoring it to a specific date (e.g., "as of 2026-04-13").

Copilot uses AI. Check for mistakes.

The implementation uses a feature flag (`linkedin-audiences-canary-version`) for safe, gradual rollout with instant rollback capability.

---

## Critical Breaking Changes

**None new in 202505 → 202603 range.**

---

## Medium Priority Changes

### 1. BATCH_CREATE Per-Element Response Schema (introduced 202502)

**Applies to**: `POST /rest/dmpSegments/{id}/users` with `X-RestLi-Method: BATCH_CREATE`

| Aspect | Pre-202502 | 202502+ (202603) |
| ----------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Validation scope | All-or-nothing: entire batch rejected on any validation error | Partial: valid elements accepted, invalid ones return per-element errors |
| Response body | Single error on failure | Array of per-element HTTP statuses (`201` success, `400` error) |
| Error attribution | No index reference | `batchIndex` field identifies failing element position |

**Impact on our implementation**: Our current code checks `res.status !== 200` for the top-level HTTP response and throws `RetryableError` for non-200 responses. Under 202603, a batch with some valid and some invalid elements may return HTTP `200` at the top level while individual elements have `400` status in the response body.

**Decision**: The existing error handling remains correct for the overall batch failure case. Per-element error handling is a potential future enhancement but is not a breaking change for the current implementation pattern (batch retries handle transient failures).
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies per-element failures in a top-level 200 response are effectively handled via retries, but the current implementation only checks res.status !== 200 and does not inspect per-element statuses in the response body. Please update this analysis to reflect that per-element 4xx responses would currently be treated as success (no retry), or add code to detect and surface per-element failures.

Suggested change
**Decision**: The existing error handling remains correct for the overall batch failure case. Per-element error handling is a potential future enhancement but is not a breaking change for the current implementation pattern (batch retries handle transient failures).
**Decision**: The existing error handling remains correct only for top-level batch failures. Per-element `400` responses inside a top-level `200` response are currently treated as success by our implementation, so they are neither surfaced nor retried. This is a known behavioral gap to monitor during rollout and should be addressed in a future implementation change if element-level failure handling becomes required.

Copilot uses AI. Check for mistakes.

### 2. Predictive Audiences API (202511)

Additive/non-breaking. New `desiredAudienceCount` field and geo-filter options for predictive audience segments. Does not affect existing DMP segment user sync flows.

---

## Low Priority / Informational

### 3. Rate Limit Alerting (202509)

LinkedIn now sends email alerts to developer app admins at 75% of daily rate limit. Rate limits themselves are unchanged.

### 4. Version Sunset Schedule

| Version | Sunset Date |
| -------------------- | ------------------- |
| **202505 (current)** | **May 15, 2026 ⚠️** |
| 202603 (target) | March 16, 2027 |

202505 is the **immediate driver** for this upgrade — it sunsets in approximately 1 month.

### 5. `accessPolicy` Field Removal (202406 — pre-range)

Already handled. The field was removed from create/update/get schema in 202406. Our implementation does not use this field.

---

## Testing Requirements

- [x] Stable version (202505) continues to work without feature flag
- [x] Canary version (202603) activated via `linkedin-audiences-canary-version` feature flag
- [ ] Manual smoke test with feature flag disabled (stable)
- [ ] Manual smoke test with feature flag enabled (canary)
- [ ] Monitor for per-element batch error responses in canary rollout

---

## Rollout Plan

1. **Phase 1**: Merge PR — feature flag off by default, production unchanged
2. **Phase 2**: Enable flag for internal Segment workspace testing
3. **Phase 3**: Gradual rollout to subset of customers
4. **Phase 4**: Full rollout, promote canary (`202603`) to stable
5. **Phase 5**: Remove feature flag and old version (`202505`)
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { LINKEDIN_AUDIENCES_API_VERSION } from './versioning-info'
import type { Features } from '@segment/actions-core'
import { LINKEDIN_AUDIENCES_API_VERSION, LINKEDIN_AUDIENCES_CANARY_API_VERSION } from './versioning-info'

export const LINKEDIN_API_VERSION = LINKEDIN_AUDIENCES_API_VERSION
export const LINKEDIN_CANARY_API_VERSION = LINKEDIN_AUDIENCES_CANARY_API_VERSION
export const BASE_URL = 'https://api.linkedin.com/rest'
export const LINKEDIN_SOURCE_PLATFORM = 'SEGMENT'

export const FLAGON_NAME = 'linkedin-audiences-canary-version'

export function getApiVersion(features?: Features): string {
return features && features[FLAGON_NAME] ? LINKEDIN_CANARY_API_VERSION : LINKEDIN_API_VERSION
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { InvalidAuthenticationError, IntegrationError, ErrorCodes } from '@segme

import type { Settings } from './generated-types'
import updateAudience from './updateAudience'
import { LINKEDIN_API_VERSION } from './constants'
import { getApiVersion } from './constants'
import { LinkedInAudiences } from './api'
import type {
RefreshTokenResponse,
Expand Down Expand Up @@ -138,15 +138,15 @@ const destination: DestinationDefinition<Settings> = {
return { accessToken: res?.data?.access_token }
}
},
extendRequest({ auth }) {
extendRequest({ auth, features }) {
// Repeat calls to the same LinkedIn API endpoint were failing due to a `socket hang up`.
// This seems to fix it: https://stackoverflow.com/questions/62500011/reuse-tcp-connection-with-node-fetch-in-node-js
const agent = new https.Agent({ keepAlive: true })

return {
headers: {
authorization: `Bearer ${auth?.accessToken}`,
'LinkedIn-Version': LINKEDIN_API_VERSION
'LinkedIn-Version': getApiVersion(features)
},
agent
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import nock from 'nock'
import { createTestEvent, createTestIntegration } from '@segment/actions-core'
import Destination from '../../index'
import { BASE_URL, LINKEDIN_SOURCE_PLATFORM } from '../../constants'
import {
BASE_URL,
LINKEDIN_SOURCE_PLATFORM,
LINKEDIN_API_VERSION,
LINKEDIN_CANARY_API_VERSION,
FLAGON_NAME
} from '../../constants'

const testDestination = createTestIntegration(Destination)

Expand Down Expand Up @@ -865,4 +871,68 @@ describe('LinkedinAudiences.updateAudience', () => {
).rejects.toThrow('The value of `source_segment_id` and `personas_audience_key` must match.')
})
})

describe('API Version Feature Flag', () => {
it('should use stable API version by default (no feature flag)', async () => {
nock(`${BASE_URL}/dmpSegments`)
.get(/.*/)
.query(() => true)
.reply(200, { elements: [{ id: 'dmp_segment_id' }] })

const batchUpdateMock = nock(`${BASE_URL}/dmpSegments/dmp_segment_id/users`)
.post(/.*/, updateUsersRequestBody)
.matchHeader('linkedin-version', LINKEDIN_API_VERSION)
.reply(200)
Comment on lines +875 to +885
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests only assert the linkedin-version header on the /users request. Since batchUpdate also sets that header explicitly, the tests won’t catch regressions where extendRequest fails to apply the feature-flagged version to other endpoints (e.g., the preceding /dmpSegments GET/POST). Add matchHeader assertions for the /dmpSegments calls as well (or refactor so only extendRequest sets the version header) to ensure the flag truly controls all LinkedIn requests.

Copilot generated this review using guidance from repository custom instructions.

await expect(
testDestination.testAction('updateAudience', {
event,
settings: {
ad_account_id: '123',
send_email: true,
send_google_advertising_id: true
},
useDefaultMappings: true,
auth,
mapping: {
personas_audience_key: 'personas_test_audience'
}
// No features parameter — stable version should be used
})
).resolves.not.toThrowError()

expect(batchUpdateMock.isDone()).toBe(true)
})

it('should use canary API version when feature flag is enabled', async () => {
nock(`${BASE_URL}/dmpSegments`)
.get(/.*/)
.query(() => true)
.reply(200, { elements: [{ id: 'dmp_segment_id' }] })

const batchUpdateMock = nock(`${BASE_URL}/dmpSegments/dmp_segment_id/users`)
.post(/.*/, updateUsersRequestBody)
.matchHeader('linkedin-version', LINKEDIN_CANARY_API_VERSION)
.reply(200)

await expect(
testDestination.testAction('updateAudience', {
event,
settings: {
ad_account_id: '123',
send_email: true,
send_google_advertising_id: true
},
useDefaultMappings: true,
auth,
mapping: {
personas_audience_key: 'personas_test_audience'
},
features: { [FLAGON_NAME]: true }
})
).resolves.not.toThrowError()

expect(batchUpdateMock.isDone()).toBe(true)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { StatsContext } from '@segment/actions-core'
import type { StatsContext, Features } from '@segment/actions-core'
import { RequestClient, RetryableError, IntegrationError, InvalidAuthenticationError } from '@segment/actions-core'
import type { Settings } from '../generated-types'
import type { Payload } from './generated-types'
Expand All @@ -12,7 +12,8 @@ export async function processPayload(
settings: Settings,
payloads: Payload[],
statsContext: StatsContext | undefined,
stateContext?: StateContext
stateContext?: StateContext,
features?: Features
) {
validate(settings, payloads)

Expand All @@ -36,7 +37,7 @@ export async function processPayload(
`endpoint:add-or-remove-users-from-dmpSegment`
])

const res = await linkedinApiClient.batchUpdate(dmpSegmentId, elements)
const res = await linkedinApiClient.batchUpdate(dmpSegmentId, elements, features)

// Handle 401 explicitly so the framework can trigger token refresh.
// Without this, 401s are swallowed by throwHttpErrors: false and converted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ const action: ActionDefinition<Settings, Payload> = {
description: 'Syncs contacts from a Personas Audience to a LinkedIn DMP Segment.',
defaultSubscription: 'event = "Audience Entered" or event = "Audience Exited"',
fields,
perform: async (request, { settings, payload, statsContext, stateContext }) => {
return processPayload(request, settings, [payload], statsContext, stateContext)
perform: async (request, { settings, payload, statsContext, stateContext, features }) => {
return processPayload(request, settings, [payload], statsContext, stateContext, features)
},
performBatch: async (request, { settings, payload, statsContext, stateContext }) => {
return processPayload(request, settings, payload, statsContext, stateContext)
performBatch: async (request, { settings, payload, statsContext, stateContext, features }) => {
return processPayload(request, settings, payload, statsContext, stateContext, features)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/** LINKEDIN_AUDIENCES_API_VERSION
* LinkedIn Audiences API version.
* LinkedIn Audiences API version (stable/production).
* API reference: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-audience-management/audience-api?view=li-lms-2024-05
* Changelog: https://learn.microsoft.com/en-us/linkedin/marketing/changelog
*/
export const LINKEDIN_AUDIENCES_API_VERSION = '202505'

/** LINKEDIN_AUDIENCES_CANARY_API_VERSION
* LinkedIn Audiences API version (canary/feature-flagged).
* Testing new version 202603 behind feature flag.
*/
export const LINKEDIN_AUDIENCES_CANARY_API_VERSION = '202603'
Loading