Skip to Content
ProvidersLinkedIn Ads

LinkedIn Ads

Category: Paid Ads
Integration type: Tenant OAuth (stored in integrations table)
External API: LinkedIn Marketing API v2


Purpose

LinkedIn Ads integration covers:

  1. Pull — Import campaign performance data (impressions, clicks, conversions, spend, leads) for reporting
  2. 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

FormatTemplate usedPlatform support
Single Image AdStatic image + headline + body + CTAAgent copy → push to Campaign Manager
Text AdHeadline (25 chars) + description (75 chars)Agent copy → push to Campaign Manager
Message Ad (Sponsored InMail)Subject + bodyAgent copy → push as DRAFT; reviewed before sending
Document AdPDF attachmentManual — agent generates copy; DM team uploads PDF

Test Cases

Unit tests (packages/tools/src/linkedin-ads.test.ts)

TestApproach
getCampaignAnalytics() builds correct dateRange formatMock axios.get; assert nested start/end year/month/day
getCampaignAnalytics() calculates cpc from spend/clicksMock data; assert cpc = spend / clicks
getCampaignAnalytics() returns 0 cpc when clicks = 0Assert no divide-by-zero
createSponsoredContent() creates UGC post as DRAFT firstAssert lifecycleState: 'DRAFT' in first call
createSponsoredContent() creates creative with PAUSED statusAssert status: 'PAUSED' in second call
verify() throws when no ad accountsMock empty elements; assert throws

© 2026 Leadmetrics — Internal use only