LinkedIn Ads
Category: Paid Ads
Integration type: Tenant OAuth (stored in integrations table)
External API: LinkedIn Marketing API v2
Purpose
LinkedIn Ads integration covers:
- Pull — Import campaign performance data (impressions, clicks, conversions, spend, leads) for reporting
- Push — Upload AI-generated ad copy created by the LinkedIn Ads Writer agent into LinkedIn Campaign Manager as draft campaigns/creatives
LinkedIn Ads is particularly valuable for B2B clients targeting by job title, company size, industry, and seniority. The platform’s Ads Analyst agent provides LinkedIn-specific recommendations around audience refinement and bid strategy.
Config Structure
OAuth flow
scope: r_ads,w_organization_social,rw_ads,r_basicprofile
Stored in integrations:
provider: 'linkedin_ads'
api_key: encrypt(access_token)
metadata: {
adAccountId: 'urn:li:sponsoredAccount:123456789',
organizationId: 'urn:li:organization:12345678',
accessToken: string,
tokenExpiresAt: string, // 60 days
}The LinkedIn Ads integration reuses the same OAuth app as the organic LinkedIn integration — the scopes are additive. If a tenant has already connected LinkedIn for organic posting, the same connection can be extended to include rw_ads.
Integration Pattern
Pulling campaign analytics
class LinkedInAdsTool {
private baseUrl = 'https://api.linkedin.com/v2';
constructor(private accessToken: string) {}
private headers() {
return {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0',
};
}
async getCampaignAnalytics(options: {
adAccountId: string; // 'urn:li:sponsoredAccount:123456789'
startDate: string; // YYYY-MM-DD
endDate: string;
pivot?: 'CAMPAIGN' | 'CREATIVE' | 'CAMPAIGN_GROUP';
}): Promise<LinkedInAdStat[]> {
const response = await axios.get(
`${this.baseUrl}/adAnalytics`,
{
headers: this.headers(),
params: {
q: 'analytics',
pivot: options.pivot ?? 'CAMPAIGN',
dateRange: JSON.stringify({
start: { year: ...parseDate(options.startDate) },
end: { year: ...parseDate(options.endDate) },
}),
accounts: options.adAccountId,
fields: [
'externalWebsiteConversions',
'costInLocalCurrency',
'impressions',
'clicks',
'leadGenerationMailContactInfoShares',
'pivotValues',
].join(','),
timeGranularity: 'MONTHLY',
},
},
);
return (response.data.elements ?? []).map((el: any) => ({
campaignId: el.pivotValues?.[0]?.split(':').pop(),
impressions: el.impressions,
clicks: el.clicks,
conversions: el.externalWebsiteConversions,
leads: el.leadGenerationMailContactInfoShares,
spend: parseFloat(el.costInLocalCurrency),
cpc: el.clicks > 0 ? parseFloat(el.costInLocalCurrency) / el.clicks : 0,
}));
}
async createSponsoredContent(options: {
adAccountId: string;
pageId: string; // Organization URN
text: string; // Ad copy
headline?: string; // For Single Image Ad
ctaLabel: string; // e.g. 'Learn More', 'Sign Up'
destinationUrl: string;
imageUrn?: string; // Pre-uploaded image asset URN
}): Promise<{ shareUrn: string; contentId: string }> {
const body: any = {
author: options.pageId,
lifecycleState: 'DRAFT',
specificContent: {
'com.linkedin.ugc.ShareContent': {
shareCommentary: { text: options.text },
shareMediaCategory: 'ARTICLE',
media: [{
status: 'READY',
originalUrl: options.destinationUrl,
title: { text: options.headline ?? '' },
description: { text: '' },
}],
},
},
visibility: {
'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC',
},
};
if (options.imageUrn) {
body.specificContent['com.linkedin.ugc.ShareContent'].shareMediaCategory = 'IMAGE';
body.specificContent['com.linkedin.ugc.ShareContent'].media = [{
status: 'READY',
media: options.imageUrn,
}];
}
const response = await axios.post(`${this.baseUrl}/ugcPosts`, body, {
headers: this.headers(),
});
const shareUrn = response.headers['x-restli-id'] as string;
// Link the share as a sponsored creative in the ad account
const creativeResponse = await axios.post(
`${this.baseUrl}/adCreativesV2`,
{
account: options.adAccountId,
type: 'SPONSORED_VIDEO', // SPONSORED_STATUS_UPDATE for image/text
reference: shareUrn,
status: 'PAUSED',
},
{ headers: this.headers() },
);
const contentId = creativeResponse.headers['x-restli-id'] as string;
return { shareUrn, contentId };
}
async getAdAccounts(): Promise<{ id: string; name: string; currency: string }[]> {
const response = await axios.get(`${this.baseUrl}/adAccountsV2`, {
headers: this.headers(),
params: { q: 'search', 'search.status.values[0]': 'ACTIVE' },
});
return (response.data.elements ?? []).map((a: any) => ({
id: a.id,
name: a.name,
currency: a.currency,
}));
}
async verify(): Promise<void> {
const accounts = await this.getAdAccounts();
if (!accounts.length) throw new Error('No active LinkedIn Ad accounts found');
}
}LinkedIn Ad Formats Supported
| Format | Template used | Platform support |
|---|---|---|
| Single Image Ad | Static image + headline + body + CTA | Agent copy → push to Campaign Manager |
| Text Ad | Headline (25 chars) + description (75 chars) | Agent copy → push to Campaign Manager |
| Message Ad (Sponsored InMail) | Subject + body | Agent copy → push as DRAFT; reviewed before sending |
| Document Ad | PDF attachment | Manual — agent generates copy; DM team uploads PDF |
Test Cases
Unit tests (packages/tools/src/linkedin-ads.test.ts)
| Test | Approach |
|---|---|
getCampaignAnalytics() builds correct dateRange format | Mock axios.get; assert nested start/end year/month/day |
getCampaignAnalytics() calculates cpc from spend/clicks | Mock data; assert cpc = spend / clicks |
getCampaignAnalytics() returns 0 cpc when clicks = 0 | Assert no divide-by-zero |
createSponsoredContent() creates UGC post as DRAFT first | Assert lifecycleState: 'DRAFT' in first call |
createSponsoredContent() creates creative with PAUSED status | Assert status: 'PAUSED' in second call |
verify() throws when no ad accounts | Mock empty elements; assert throws |
Related
- LinkedIn Provider — organic LinkedIn page posting (same OAuth)
- Google Ads Provider — Google paid campaigns
- Meta Ads Provider — Facebook/Instagram paid campaigns
- Ads Analyst Agent — cross-platform performance analysis
- Tool Integration Layer — OAuth management