adwarp

Start with 1 prompt

Paste it into Claude Code, Codex or Cursor. Your agent installs Adwarp, walks you through your LinkedIn app and checks the connection.

setup prompt
Set up Adwarp for me. It's the open-source ad agent for LinkedIn Ads and Google Ads: https://github.com/adwarp-dev/adwarp

1. Check that Node.js 20 or newer is installed.
2. Walk me through creating my own LinkedIn developer app with the Advertising API product: https://github.com/adwarp-dev/adwarp/blob/main/docs/linkedin-app.md
3. Save the app's client ID and secret to ~/.adwarp/config.json as clientId and clientSecret.
4. Run "npx -y adwarp auth login" and wait while I approve access in my browser.
5. Add Adwarp to the AI client I'm using right now as an MCP server that runs "npx -y adwarp mcp". In Claude Code that's: claude mcp add adwarp -- npx -y adwarp mcp
6. Check the connection by listing my ad accounts, then save the one I pick as defaultAccountId in ~/.adwarp/config.json.

Don't create or change anything in my ad accounts during setup. LinkedIn campaigns start as drafts, so nothing spends until I turn it on.
adwarp
GitHub

Start with 1 prompt

Paste it into Claude Code, Codex or Cursor. Your agent installs Adwarp, walks you through your LinkedIn app and checks the connection.

setup prompt
Set up Adwarp for me. It's the open-source ad agent for LinkedIn Ads and Google Ads: https://github.com/adwarp-dev/adwarp

1. Check that Node.js 20 or newer is installed.
2. Walk me through creating my own LinkedIn developer app with the Advertising API product: https://github.com/adwarp-dev/adwarp/blob/main/docs/linkedin-app.md
3. Save the app's client ID and secret to ~/.adwarp/config.json as clientId and clientSecret.
4. Run "npx -y adwarp auth login" and wait while I approve access in my browser.
5. Add Adwarp to the AI client I'm using right now as an MCP server that runs "npx -y adwarp mcp". In Claude Code that's: claude mcp add adwarp -- npx -y adwarp mcp
6. Check the connection by listing my ad accounts, then save the one I pick as defaultAccountId in ~/.adwarp/config.json.

Don't create or change anything in my ad accounts during setup. LinkedIn campaigns start as drafts, so nothing spends until I turn it on.

The LinkedIn Marketing API: what the docs leave out

What breaks between API approval and a working integration, with curl and TypeScript samples and a note on where Microsoft Learn covers each part.

By Updated 18 min read

The LinkedIn Marketing API has no API key: you create a developer app tied to a LinkedIn Page, get approved for the Advertising API, and call https://api.linkedin.com/rest with a 3-legged OAuth token. Every call also needs a LinkedIn-Version header (YYYYMM, with 202610 the latest) and X-Restli-Protocol-Version: 2.0.0, and the rest of this post covers what the reference leaves out, taken from Adwarp's open-source client.

A lot of it is in Microsoft Learn, in places you wouldn't look, like a schema table row or a shared page from 2021. Most of the trouble starts with the samples. The create-campaign request on the campaigns page leaves out politicalIntent, which the schema table on that same page marks as required. Where the docs say nothing at all, I say so.

Microsoft Learn pages checked on November 7, 2026, on the October 2026 (202610) version of the docs.

How access works: app, Page, tiers and scopes

A working call needs a LinkedIn Page, a developer app associated with it, approval for the Advertising API, and an OAuth token from a member who has a role on the ad account.

Fig. 01A call gets past the Page, the app, product approval and a member's token, then edits at most 5 ad accounts on the Development tier.

Create the app and request products

Step 1 of Microsoft Learn's quick start is "Create a LinkedIn Page", because the app gets associated with it. Then create the app in the Developer Portal, add a redirect URL on the Auth tab, and apply on the Products tab.

The Advertising API covers campaigns, creatives, targeting and reporting. List uploads need Audiences too (Microsoft Learn calls it the Matched Audiences API), and LinkedIn only takes that application from apps that already have the Advertising API. Competitor ads need LinkedIn Ad Library, a third product covered further down.

Fig. 02On the Products tab, Audiences opens only to apps that already have the Advertising API, and Ad Library goes through its own review.

If LinkedIn rejects you, you start over. Its Advertising API page says to "create a new app, and submit a new application." Adwarp's LinkedIn app guide walks through the whole setup.

Development and Standard tiers

Every approved app starts on the Development tier. Microsoft Learn's access page (updated October 19, 2026) sets the limits:

Tier, Read, Create, Edit, How you get it
TierReadCreateEditHow you get it
DevelopmentNo limit on ad accounts you administer1 test ad account through the APIUp to 5 ad accountsGranted on approval
StandardNo limit on ad accounts you administerUnlimited ad accountsUnlimitedA support ticket on the Developer Support Portal, with a video of your platform creating or editing campaigns

On Development you also map each ad account to the app. The quick start lists the clicks (copy the 9-digit account ID from Campaign Manager, then Products, View Ad Accounts, Add Ad Account) but not what happens if you skip them. The account list comes back empty, with a 200 and no error, as Adwarp's setup guide warns.

Scopes and the roles behind them

Scope, Comes with, Allows
ScopeComes withAllows
rw_adsAdvertising APICreate and edit campaigns, on accounts where the member is ACCOUNT_BILLING_ADMIN, ACCOUNT_MANAGER, CAMPAIGN_MANAGER or CREATIVE_MANAGER
r_ads_reportingAdvertising APIRead adAnalytics
w_organization_socialAdvertising APIPost as the Page, which inline image ads need
rw_conversionsConversions APIManage conversion rules and upload conversions
rw_dmp_segmentsMatched Audiences APICreate and manage matched audiences

A scope never goes past the member's role: a VIEWER stays read-only even with rw_ads, per the campaign groups page. The access page lists every Marketing API scope as a member permission (3-legged), so ads have no client-credentials path.

To add a scope later, send the member through consent again, because a refresh keeps the original grant's scopes. Adwarp notes this in auth.ts, and Microsoft Learn's refresh token page doesn't mention it.

Fig. 03A refresh keeps the scopes the member first agreed to, so adding one means sending them through consent again.

Token lifetimes

Access tokens last 60 days and refresh tokens 365 days, per Programmatic refresh tokens, and refreshing doesn't reset the 365. In the page's example, a refresh on day 59 leaves the refresh token 306 days.

Fig. 04A refresh on day 59 starts a new 60-day access token, while the refresh token keeps its original end date, now 306 days away.

Take the real lifetimes from your own token response. The sample response on that page shows "expires_in": 86400, 1 day in seconds, and "refresh_token_expires_in": 525600, which is 365 days only if you read it as minutes. LinkedIn can also revoke tokens early, so keep the login flow working.

The 2 headers every call needs

Every call to /rest carries LinkedIn-Version and X-Restli-Protocol-Version: 2.0.0. Without the version, LinkedIn answers 400 VERSION_MISSING, since it never picks one for you. Without the protocol header nothing complains at that point: the call falls back to protocol 1.0, per the protocol version page, and 1.0 doesn't understand the List() syntax used in the rest of this post.

Fig. 05LinkedIn rejects a call without its version header with a 400, but a call without the protocol header goes through on protocol 1.0 and nothing warns you.
curl
curl 'https://api.linkedin.com/rest/adAccounts?q=search' \
  -H "Authorization: Bearer $LI_TOKEN" \
  -H 'LinkedIn-Version: 202610' \
  -H 'X-Restli-Protocol-Version: 2.0.0'

Versioning promises a new version each month and at least 1 year of support for each. On November 7, 2026, it names 202610 as the latest and warns that 202511 sunsets on November 16, 2026, while its table of example headers still ends at November 2025. A sunset version gets a 426 NONEXISTENT_VERSION, per the error responses page.

Fig. 06Each monthly version lives at least 1 year, so a pin like Adwarp's 202605 needs a deliberate bump before its sunset.

Pin a version in config and bump it with the changelog open. Adwarp pins 202605 in config.ts, which the 1-year rule covers until at least May 2027. The samples below all use this helper, which keeps the query string away from URLSearchParams (the encoding section explains why):

linkedin.ts
const BASE = "https://api.linkedin.com/rest";
const VERSION = "202610"; // pinned, bumped on purpose

type Opts = { method?: string; query?: string; body?: unknown; headers?: Record<string, string> };

export async function li(path: string, opts: Opts = {}) {
  const url = `${BASE}${path}${opts.query ? `?${opts.query}` : ""}`; // query is pre-encoded
  const res = await fetch(url, {
    method: opts.method ?? "GET",
    headers: {
      Authorization: `Bearer ${process.env.LI_TOKEN}`,
      "LinkedIn-Version": VERSION,
      "X-Restli-Protocol-Version": "2.0.0",
      ...(opts.body === undefined ? {} : { "Content-Type": "application/json" }),
      ...opts.headers,
    },
    body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
  });
  const text = await res.text();
  if (!res.ok) throw new Error(`LinkedIn ${res.status}: ${text}`);
  return { data: text ? JSON.parse(text) : undefined, id: res.headers.get("x-restli-id") ?? undefined };
}

The naming map

LinkedIn's names sit 1 level off from other ad platforms. A LinkedIn campaign group is what most people call a campaign, and a LinkedIn campaign holds the targeting and budget that Google Ads keeps on an ad group. The API uses LinkedIn's names, so when a teammate reports a bug in "the campaign", ask which one.

Fig. 07The campaign, where targeting and budget live, sits 2 levels under the ad account and matches what other platforms call an ad group.
You'd say, Campaign Manager, API resource, URN, Holds
You'd sayCampaign ManagerAPI resourceURNHolds
Ad accountAd accountadAccountsurn:li:sponsoredAccountbilling, currency, everything below
CampaignCampaign groupadCampaignGroupsurn:li:sponsoredCampaignGroupstatus, total budget, run schedule
Ad groupCampaignadCampaignsurn:li:sponsoredCampaigntargeting, budget, bid, schedule, format
AdAdcreativesurn:li:sponsoredCreativethe post it sponsors, its own intendedStatus
AudienceMatched audiencedmpSegments, then adSegmentsurn:li:adSegmentthe list, attached to a campaign's targetingCriteria

URNs (uniform resource names) are LinkedIn's IDs. Paths mostly take the number while bodies and filters take the full URN, and Adwarp keeps a few 1-line converters in urns.ts for it.

Restli 2.0 encoding, the part that breaks URL builders

Rest.li 2.0 query strings are small object literals. The parentheses, colons, commas and List(...) stay literal, and only the values inside, usually URNs, get percent-encoded. The rule is on the protocol version page, a shared page dated January 2021, not on the endpoint pages that need it.

Fig. 08Only the URN gets percent-encoded, and the parentheses and List() around it must reach LinkedIn untouched.

Audience Counts prints its request both ways:

audienceCounts
# readable, not sendable
q=targetingCriteriaV2&targetingCriteria=(include:(and:List((or:(urn:li:adTargetingFacet:locations:List(urn:li:geo:103644278))))))

# what LinkedIn accepts
q=targetingCriteriaV2&targetingCriteria=(include:(and:List((or:(urn%3Ali%3AadTargetingFacet%3Alocations:List(urn%3Ali%3Ageo%3A103644278))))))

Pass the encoded string through URLSearchParams and it gets encoded a second time: parentheses turn into %28, the structural colons into %3A, and the URN's %3A into %253A. The same page warns that Postman "or similar API tools may not support these types of calls" and suggests curl if you get Invalid query parameters passed to request. Build the string yourself and append it untouched:

restli.ts
// Adapted from Adwarp's targeting.ts (MIT):
// https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/targeting.ts
type Spec = { include: Record<string, string[]>; exclude?: Record<string, string[]> };

const enc = encodeURIComponent;
const facet = (name: string) => enc(`urn:li:adTargetingFacet:${name}`);
const list = (urns: string[]) => `List(${urns.map(enc).join(",")})`;
const pairs = (rec: Record<string, string[]> = {}) => Object.entries(rec).filter(([, urns]) => urns.length > 0);

export function encodeTargetingCriteria(spec: Spec): string {
  const and = pairs(spec.include).map(([name, urns]) => `(or:(${facet(name)}:${list(urns)}))`).join(",");
  let body = `include:(and:List(${and}))`;
  const ex = pairs(spec.exclude);
  if (ex.length) body += `,exclude:(or:(${ex.map(([name, urns]) => `${facet(name)}:${list(urns)}`).join(",")}))`;
  return `(${body})`;
}

// usage: facets are ANDed, URNs inside a facet are ORed
const query = `q=targetingCriteriaV2&targetingCriteria=${encodeTargetingCriteria({
  include: { locations: ["urn:li:geo:103644278"], seniorities: ["urn:li:seniority:6"] },
})}`;
const { data } = await li("/audienceCounts", { query }); // data.elements[0].total

Reporting dates follow the same style: dateRange=(start:(year:2026,month:10,day:1),end:(year:2026,month:10,day:31)). Watch for 2 more cases:

  • A URN in a path is encoded whole, colons included, as in /adAccounts/123456789/creatives/urn%3Ali%3AsponsoredCreative%3A120491345, which the creatives page shows. Send it raw and you get Syntax exception in path variables. The docs mention that message once, in a test-account error table, and the fix they give there, removing the protocol header, won't help with a path.
  • The old dotted parameters (target.includedTargetingFacets...) now return 400 on an audience estimate. The current Audience Counts page never mentions them, and Adwarp's AGENTS.md records the 400.

Create the 4 objects, with the fields the reference doesn't flag

Most of these fields are marked required in some schema table, but the samples skip some of them and nothing says which rules a draft still has to meet. Everything below is created as DRAFT, so nothing serves or spends.

Fig. 09Each create needs the ID the previous one returned in x-restli-id, and each has 1 field that trips first attempts.

Ad accounts

List accounts first. If this works, the token, scope, role and account mapping are all fine. With no search criteria, q=search returns every account the member can reach.

accounts.ts
const { data } = await li("/adAccounts", { query: "q=search" });
for (const a of data.elements ?? []) console.log(a.id, a.name, a.status, a.currency);

An empty elements array with a 200 usually means the account isn't mapped yet, or the member has a role on some other account.

Campaign groups

The campaign groups page marks runSchedule.start as required. The campaigns page says a DRAFT postpones "some validation" until activation, which makes it easy to assume a draft group can skip the schedule. Adwarp's orchestrator learned otherwise and carries the comment "runSchedule is required even for drafts". A draft group also takes only draft campaigns (ONLY_DRAFT_CAMPAIGNS_ALLOWED_IN_DRAFT_CAMPAIGN_GROUPS on the error responses page).

The new ID arrives in the x-restli-id response header with an empty body. The campaign groups page never shows a create response, so run curl with -i to see it.

curl
curl -i -X POST 'https://api.linkedin.com/rest/adAccounts/123456789/adCampaignGroups' \
  -H "Authorization: Bearer $LI_TOKEN" \
  -H 'LinkedIn-Version: 202610' \
  -H 'X-Restli-Protocol-Version: 2.0.0' \
  -H 'Content-Type: application/json' \
  --data '{
    "account": "urn:li:sponsoredAccount:123456789",
    "name": "Q1 pipeline test",
    "status": "DRAFT",
    "runSchedule": { "start": 1796083200000 }
  }'
# HTTP/2 201
# x-restli-id: 600000001
campaign-group.ts
const { id: groupId } = await li("/adAccounts/123456789/adCampaignGroups", {
  method: "POST",
  body: {
    account: "urn:li:sponsoredAccount:123456789",
    name: "Q1 pipeline test",
    status: "DRAFT",
    runSchedule: { start: Date.parse("2026-12-01T00:00:00Z") }, // epoch ms
  },
});

Campaigns

The schema table on the campaigns page marks politicalIntent and offsiteDeliveryEnabled as required and says offsiteDeliveryEnabled has "no default set". The create sample on that page has offsiteDeliveryEnabled and no politicalIntent. politicalIntent takes NOT_POLITICAL, POLITICAL or NOT_DECLARED, and if you target the European Union your interface has to show the advertiser a consent line that the page quotes in full. offsiteDeliveryEnabled switches the LinkedIn Audience Network on or off.

locale, costType, unitCost, targetingCriteria and a daily or total budget are required too. audienceExpansionEnabled and runSchedule are optional, and Adwarp sends both anyway. Campaign Manager switches Audience Network and Audience Expansion on for new ad sets, while every ad set Adwarp creates has both off and no Adwarp tool turns them back on, per its safety rules.

Fig. 10The 2 reach switches as Campaign Manager sets them on a new ad set, next to what Adwarp's campaign body sends.

costType is CPC, CPM or CPV (cost per click, per 1,000 impressions, per video view). Maximum Delivery in Campaign Manager is optimizationTargetType: MAX_CLICK: LinkedIn sets the bid, bills by impression and ignores any unitCost. The docs get close, noting that auto-bidding on legacy objectives always uses CPM and that unitCost there has "no validations". A manual bid of 0 doesn't deliver at all. "Rotate ads evenly" is creativeSelection: ROUND_ROBIN, and no ROTATE_EVENLY value exists.

campaign.ts
const { id: campaignId } = await li("/adAccounts/123456789/adCampaigns", {
  method: "POST",
  body: {
    account: "urn:li:sponsoredAccount:123456789",
    campaignGroup: `urn:li:sponsoredCampaignGroup:${groupId}`,
    name: "VP marketing, US",
    type: "SPONSORED_UPDATES",
    status: "DRAFT",
    costType: "CPC",
    unitCost: { amount: "12.00", currencyCode: "USD" }, // manual bid
    dailyBudget: { amount: "50.00", currencyCode: "USD" },
    locale: { country: "US", language: "en" },
    runSchedule: { start: Date.parse("2026-12-01T00:00:00Z") },
    creativeSelection: "ROUND_ROBIN",
    targetingCriteria: {
      include: { and: [{ or: { "urn:li:adTargetingFacet:locations": ["urn:li:geo:103644278"] } }] },
    },
    politicalIntent: "NOT_POLITICAL", // required, missing from the docs sample
    offsiteDeliveryEnabled: false, // required, no default
    audienceExpansionEnabled: false,
  },
});

Creatives

A single-image ad is a post plus a creative, and POST /adAccounts/{id}/creatives?action=createInline makes both in 1 call. The post is Direct Sponsored Content owned by your Page, so the token needs w_organization_social. Upload the image first with POST /images?action=initializeUpload to get its URN.

Fig. 11Each part of an inline image ad maps to a field, and the button's destination comes from content.article.source, which the ad editor reads.
curl
curl -i -X POST 'https://api.linkedin.com/rest/adAccounts/123456789/creatives?action=createInline' \
  -H "Authorization: Bearer $LI_TOKEN" \
  -H 'LinkedIn-Version: 202610' \
  -H 'X-Restli-Protocol-Version: 2.0.0' \
  -H 'Content-Type: application/json' \
  --data '{
    "creative": {
      "campaign": "urn:li:sponsoredCampaign:700000001",
      "intendedStatus": "DRAFT",
      "inlineContent": { "post": {
        "adContext": { "dscAdAccount": "urn:li:sponsoredAccount:123456789", "dscStatus": "ACTIVE" },
        "author": "urn:li:organization:11111111",
        "commentary": "Ad copy goes here.",
        "visibility": "PUBLIC",
        "distribution": { "feedDistribution": "NONE" },
        "lifecycleState": "PUBLISHED",
        "isReshareDisabledByAuthor": false,
        "contentLandingPage": "https://example.com/demo",
        "contentCallToActionLabel": "LEARN_MORE",
        "content": { "article": {
          "title": "Headline under the image",
          "thumbnail": "urn:li:image:EXAMPLE",
          "source": "https://example.com/demo"
        } }
      } }
    }
  }'
# x-restli-id: urn:li:sponsoredCreative:800000001

The content.article block is the part no doc covers. With a click URL, Campaign Manager's ad editor reads the destination from content.article.source and leaves its form blank if you only set contentLandingPage. Adwarp found this and wrote it into creatives.ts. Ads without a URL can use the plain content.media shape.

To sponsor a post that already exists, such as a member's post for a thought leader ad, the creative points at the post's urn:li:ugcPost: or urn:li:share: URN in content.reference instead. A member's post can be sponsored only after the member approves a request sent from Campaign Manager, and the API can't send that request. Adwarp's create_post_ad, added in 0.5.0, makes this kind of ad as a draft once approval is in place.

Fig. 12Sponsoring a member's post needs their approval in Campaign Manager before the API can create a draft creative that points at it.

intendedStatus belongs to the creative, but the creatives page says the parent's status wins in effect, so an ACTIVE creative in a PAUSED campaign doesn't serve. Creatives have no dates of their own either, and the campaigns page describes runSchedule as the "date range to run associated creatives". Here x-restli-id holds the full URN (the docs' example is urn:li:sponsoredCreative:120491345), so strip the prefix if you store numbers.

Updates: PARTIAL_UPDATE, patch.$set and 3 traps

An update is a POST to the entity's URL with X-RestLi-Method: PARTIAL_UPDATE and a body of {"patch": {"$set": {...}}}, answered with a 204. The ad accounts and creatives pages include that header in their samples, while the campaign groups update sample leaves it out. Adwarp sends it on every patch.

Fig. 13A $set on a conversion's campaigns array replaces the list, so writing only the new campaign drops the 3 already attached.
pause-ad.ts
const urn = encodeURIComponent("urn:li:sponsoredCreative:800000001"); // encoded whole, see above
await li(`/adAccounts/123456789/creatives/${urn}`, {
  method: "POST",
  headers: { "X-RestLi-Method": "PARTIAL_UPDATE" },
  body: { patch: { $set: { intendedStatus: "PAUSED" } } },
});
Fig. 14An update reaches only 4 creative fields, so new copy or a new image means deleting the creative and creating a fresh draft.
  1. A conversion's campaigns field replaces the whole array. Read the conversion, append your campaign URN, and write the full list back (associatedCampaigns is read-only). Conversion tracking does say this: "The update replaces the whole field, rather than appending the new updated values." It also documents campaignConversions, which links 1 campaign to 1 conversion with a PUT and skips the array. The conversion tracking guide covers the rest.
  2. Edits to a live ad's post are ignored. The creatives page lists what an update can change: intendedStatus, the lead gen call to action, name and 1 event-ad flag. Post text and images aren't on that list, so to change copy you delete the creative and create a new draft, as Adwarp's README tells its users.
  3. Status changes have their own rules. Pausing a creative still in review returns 400, and the creatives page says so: "You can't pause an ad creative in review." Going the other way, an ACTIVE creative stays dark while its campaign isn't ACTIVE.

Paging and reporting

The q=search finders for ad accounts, campaign groups and campaigns page by cursor. Send a pageSize (100 if you leave it out, 1,000 at most) and pass metadata.nextPageToken back as pageToken until none comes back. The docs cover this: "From 202401 version and above, we have moved from index based pagination to cursor based pagination."

Fig. 15Paging with start= returns page 1 forever, while pageToken walks forward until no next token comes back.

The docs don't say what start= does. LinkedIn ignores it silently and returns page 1 every time, so a loop that increments start never ends on its own. The comment at the top of Adwarp's search.ts warns about it.

search-all.ts
export async function searchAll(path: string) {
  const all: unknown[] = [];
  let token: string | undefined;
  do {
    const { data } = await li(path, { query: `q=search&pageSize=300${token ? `&pageToken=${token}` : ""}` });
    all.push(...(data.elements ?? []));
    token = data.metadata?.nextPageToken;
  } while (token);
  return all;
}

All reporting goes through adAnalytics. q=analytics takes 1 pivot (ACCOUNT, CAMPAIGN_GROUP, CAMPAIGN, CREATIVE or a demographic pivot) and q=statistics takes up to 3. The reporting schema allows timeGranularity of ALL, DAILY, MONTHLY or YEARLY, so weekly numbers mean pulling daily rows and bucketing them yourself.

analytics.ts
const campaigns = `List(${encodeURIComponent("urn:li:sponsoredCampaign:700000001")})`;
const { data } = await li("/adAnalytics", {
  query:
    "q=analytics&pivot=CREATIVE&timeGranularity=DAILY" +
    "&dateRange=(start:(year:2026,month:10,day:1),end:(year:2026,month:10,day:31))" +
    `&campaigns=${campaigns}&fields=impressions,clicks,costInUsd,dateRange,pivotValues`,
});
// each row (sample values): { pivotValues: ["urn:li:sponsoredCreative:..."], impressions: 1840, clicks: 22, costInUsd: "97.31" }

The reporting page lists the limits: no pagination, at most 15,000 elements per response, and up to 20 metrics in fields. LinkedIn also throttles any app that pulls more than 45 million metric values in 5 minutes.

Spend comes back as a string. costInUsd is typed BigDecimal, and the docs' own sample shows "costInLocalCurrency": "19.91833", so parse before you add. Zero-spend drafts are missing too, because the endpoint returns "an empty response if there's no activity to report". To show an account's structure, list it with q=search and join the analytics rows onto that.

Fig. 16In the sample row, costInUsd arrives as the string "97.31", and a draft that spent nothing has no row at all.

Matched audiences

A matched audience starts as a list upload and ends as an adSegment you can target. The list uploads page (updated August 17, 2026) documents the flow properly, and Adwarp's dmpSegments.ts runs it:

Fig. 17Hashed rows match over up to 48 hours, and the audience serves only once 300 members match.
  1. POST /dmpSegments?action=generateUploadUrl with your account as owner. The signed URL comes back in value.
  2. Upload the CSV (comma-separated values) file to that URL. The media ID is in the location response header.
  3. POST /dmpSegments with sourcePlatform: LIST_UPLOAD and type: USER_LIST_UPLOAD for contacts or COMPANY_LIST_UPLOAD for companies.
  4. POST /dmpSegments/{id}/listUploads with the media URN as inputFile.
  5. Poll GET /dmpSegments/{id} until a destination reads READY. Its destinationSegmentId is the urn:li:adSegment you put under urn:li:adTargetingFacet:audienceMatchingSegments in a campaign's targeting.

Emails go up as SHA-256 hex of the lowercased address with whitespace removed:

hash.ts
import { createHash } from "node:crypto";
const hashEmail = (email: string) => createHash("sha256").update(email.trim().toLowerCase()).digest("hex");

For contact lists the page asks for "at least 10,000 rows with a maximum list size of 300,000" and a minimum audience count of 300. In Adwarp's testing the API takes a USER_LIST_UPLOAD from 300 rows, so Adwarp warns under 10,000 instead of refusing, since match rate decides whether a small list ever reaches 300 matched members. Audience Counts states the same floor: 300 is the minimum to run a campaign, and under it the estimate's total reads 0.

The page says to wait 5 seconds between creating the segment and attaching the list. Adwarp still got 404s after that, so it retries the attach and deletes the segment if it never takes. Matching then runs up to 48 hours.

Fig. 18After the documented 5-second wait the attach can still return 404, so Adwarp retries it and deletes the segment if it never takes.

Company lists need at least 1 of companyname, companydomain, companywebsite or linkedincompanypageurl, and LinkedIn recommends 1,000 companies or more. Adwarp's cleaner rewrites bare domains as full https:// website URLs before upload. Building lists from a CRM is on LinkedIn Ads audiences.

The Ad Library API

LinkedIn's public Ad Library has its own API behind its own product, LinkedIn Ad Library, which you request on the Products tab and which LinkedIn vets separately. Until it's granted, the endpoint answers 403 ACCESS_DENIED. I couldn't find a Microsoft Learn reference page for it on November 7, 2026, so this section comes from Adwarp's adLibraryApi.ts, written against live responses.

Fig. 19The Ad Library API returns format, advertiser, targeting and impressions for ads served in the European Union, never the copy or image.
curl
curl 'https://api.linkedin.com/rest/adLibrary?q=criteria&advertiser=Example%20Inc&countries=List(urn%3Ali%3Acountry%3Aus)&count=25&start=0' \
  -H "Authorization: Bearer $LI_TOKEN" \
  -H 'LinkedIn-Version: 202610' \
  -H 'X-Restli-Protocol-Version: 2.0.0'

The criteria finder takes keyword, advertiser (a display name) and countries, with no date range or company ID, so a common name pulls in other advertisers too. Unlike q=search, it pages with start and count, up to 25 at a time.

Each element has adUrl, isRestricted and a details object with:

  • type, the format, such as SPONSORED_STATUS_UPDATE
  • advertiser, with advertiserName, adPayer and advertiserUrl
  • adTargeting, facets with included and excluded segments, empty ones included
  • adStatistics, only for ads served in the European Union: first and latest impression dates, totalImpressions as a range, and impression share by country

For the ad's text and image you get only adUrl, the public detail page. The endpoint is throttled per minute, so Adwarp spaces pages 2.2 seconds apart and waits 60 seconds after a 429 before retrying the same page. Its Ad Library page shows how it uses the results.

Fig. 20Page 4 gets a 429 after 3 pages spaced 2.2 seconds apart, and Adwarp asks for it again 60 seconds later.

Errors and rate limits

The last column says whether Microsoft Learn warns you about each one.

Fig. 21Most failures in the table come back with an error code, and the 2 that answer with a normal 200 are the ones the docs never describe.
Symptom, Cause, Fix, In the docs?
SymptomCauseFixIn the docs?
400 VERSION_MISSINGNo LinkedIn-Version headerAdd itYes, error responses page
426 NONEXISTENT_VERSIONSunset versionBump the versionYes, error responses page
403 ACCESS_DENIED on /adLibraryAd Library product not grantedRequest the productNo reference page
400 Syntax exception in path variablesURN in the path not encodedEncode the whole URNOnly for test accounts, with another fix
400 on an audience estimateOld dotted target.includedTargetingFacets parametersUse q=targetingCriteriaV2No
400 pausing a creativeCreative still in reviewWait for the reviewYes, creatives page
200 with an empty account listAccount not mapped on DevelopmentMap the accountThe mapping, not the symptom
The same first page foreverstart= on a q=search finderUse pageTokenPaging yes, start no
429Daily quota, infrastructure protection or the reporting capBack off, retry laterYes, Retry-After no

Rate limits describes 2 daily quotas, 1 per application and 1 per member per application, and both reset at midnight Coordinated Universal Time (UTC). The numbers aren't published. The app's Analytics tab in the Developer Portal shows each endpoint's limit, but only for endpoints called that day, so make 1 test call to see a new one.

Fig. 22Daily quotas reset at midnight Coordinated Universal Time, with an email to admins at 75% and a 429 past 100%.

Admins get an email when the app passes 75% of a quota. That alert covers application limits only and arrives 1 to 2 hours late. Past the limit you get 429s, and LinkedIn may also send 429s "as part of infrastructure protection" that clear on their own. The page says nothing about Retry-After. Adwarp's http.ts retries 429 and 5xx responses with backoff and honors Retry-After when LinkedIn sends one.

Reference code

Each call above has a working version in github.com/adwarp-dev/adwarp, under the MIT license. Start with these files:

Fig. 236 files in the Adwarp repo hold working versions of every call in this post.
File, Shows
FileShows
http.tsThe 2 headers, retries on 429 and 5xx, x-restli-id, the raw query escape hatch
targeting.tsRestli encoding of targetingCriteria and the audience estimate
search.tspageToken paging for q=search finders
campaigns.tsThe required campaign fields and PARTIAL_UPDATE on campaigns
creatives.tsInline image ads, the content.article shape, encoded URNs in paths
AGENTS.mdEvery gotcha in this post as a "do not regress" list

Adwarp wraps these calls in tools for Claude and other clients of the Model Context Protocol (MCP), such as estimate_audience, upload_audience_csv and inspect_competitor_ads, and runs the same calls from your terminal as a CLI. Campaigns and ads it creates start as drafts and go live only when you explicitly ask. The tool reference lists every tool, and LinkedIn Ads shows them at work. Google's version of this setup is in Google Ads API access.

Put it to work

LinkedIn Ads

Adwarp runs these same calls from your terminal and from AI clients, and every campaign and ad it creates starts as a draft.

adwarp.dev/linkedin-ads

By Stan Rymkiewicz, maintainer of adwarp. Published November 7, 2026.

FAQ

Questions Short answers, with sources.

Ask on GitHub
Is the LinkedIn API free?

LinkedIn's Advertising API product page lists no fee for access. Approval is the gate: you apply with a developer app and LinkedIn reviews the use case. Ad spend on campaigns you run through the API is billed to the ad account as normal.

Does LinkedIn allow API access for ads?

Yes, through the Advertising API, which you request on your developer app's Products tab. Every approved app starts on the Development tier, which can read without limit and edit up to 5 ad accounts. Unlimited editing needs the Standard tier, requested through a support ticket.

Create a LinkedIn app

How do I get a LinkedIn Ads API key?

There is no API key. You create a developer app associated with a LinkedIn Page, get approved for the Advertising API, and send each request with a 3-legged OAuth access token from a member who has a role on the ad account. Access tokens last 60 days and refresh tokens 365 days.

Which LinkedIn-Version should I send, and what happens when it's sunset?

On November 7, 2026, the latest version is 202610, and LinkedIn supports each monthly version for at least 1 year. A request without the header returns 400 VERSION_MISSING, and a sunset version returns 426 NONEXISTENT_VERSION. Pin a version in config and bump it on purpose.

Why does my ad account list come back empty?

On the Development tier, each ad account has to be mapped to your app in the Developer Portal, under Products, View Ad Accounts. The other common cause is a token from a member with no role on that account. Both return a normal 200 with an empty list, not an error.

Can I use 2-legged client credentials for ads?

No. Microsoft Learn lists every Marketing API permission as a member permission, which means the 3-legged flow where a LinkedIn member signs in and consents. Plan for a login screen and a refresh token you store per member.

Can I edit a live sponsored post through the API?

Not its text or image. A creative update can change its status, its name, the lead gen call to action and 1 event-ad flag, and LinkedIn ignores edits to a live ad's post. To change copy, delete the creative and create a new draft.

Is LinkedIn ads CPC or CPM?

Either, plus CPV for video: the campaign's costType is CPC (cost per click), CPM (cost per 1,000 impressions) or CPV (cost per video view). Maximum Delivery, MAX_CLICK in the API, bids automatically and bills by impression, so any unitCost you send is ignored.

Does the Ad Library API return the ad's copy?

No. It returns the ad's format, advertiser, targeting facets and, for ads served in the European Union, impression ranges and dates. For the copy and image you get only the URL of the ad's public detail page.

Competitor ads in Adwarp

What are the LinkedIn Marketing API rate limits?

LinkedIn doesn't publish them. Each app and each member has a daily quota per endpoint that resets at midnight Coordinated Universal Time, visible in the Developer Portal's Analytics tab after you call that endpoint once. Admins get an email at 75% of an app quota, and calls over the limit return 429.

Free and open source MIT licensed. Run it locally, self-host the endpoint, or read every line on GitHub.

$ claude mcp add adwarp -- npx -y adwarp mcp

Read the docs

GitHub