# 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 Stan Rymkiewicz, maintainer of adwarp. Updated November 7, 2026.*

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](https://github.com/adwarp-dev/adwarp).

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. 01: A 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](https://learn.microsoft.com/en-us/linkedin/marketing/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. 02: On 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](https://developer.linkedin.com/product-catalog/marketing/advertising-api) says to "create a new app, and submit a new application." Adwarp's [LinkedIn app guide](https://adwarp.dev/docs/linkedin-app) walks through the whole setup.

### Development and Standard tiers

Every approved app starts on the Development tier. [Microsoft Learn's access page](https://learn.microsoft.com/en-us/linkedin/marketing/getting-access) (updated October 19, 2026) sets the limits:

| Tier | Read | Create | Edit | How you get it |
| --- | --- | --- | --- | --- |
| Development | No limit on ad accounts you administer | 1 test ad account through the API | Up to 5 ad accounts | Granted on approval |
| Standard | No limit on ad accounts you administer | Unlimited ad accounts | Unlimited | A 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 |
| --- | --- | --- |
| `rw_ads` | Advertising API | Create and edit campaigns, on accounts where the member is `ACCOUNT_BILLING_ADMIN`, `ACCOUNT_MANAGER`, `CAMPAIGN_MANAGER` or `CREATIVE_MANAGER` |
| `r_ads_reporting` | Advertising API | Read `adAnalytics` |
| `w_organization_social` | Advertising API | Post as the Page, which inline image ads need |
| `rw_conversions` | Conversions API | Manage conversion rules and upload conversions |
| `rw_dmp_segments` | Matched Audiences API | Create 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](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-campaign-groups). 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](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/auth.ts), and Microsoft Learn's refresh token page doesn't mention it.

*Fig. 03: A 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](https://learn.microsoft.com/en-us/linkedin/shared/authentication/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. 04: A 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](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/protocol-version), and 1.0 doesn't understand the `List()` syntax used in the rest of this post.

*Fig. 05: LinkedIn 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`:
```bash
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](https://learn.microsoft.com/en-us/linkedin/marketing/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](https://learn.microsoft.com/en-us/linkedin/marketing/error-responses).

*Fig. 06: Each 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`:
```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. 07: The 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 |
| --- | --- | --- | --- | --- |
| Ad account | Ad account | `adAccounts` | `urn:li:sponsoredAccount` | billing, currency, everything below |
| Campaign | Campaign group | `adCampaignGroups` | `urn:li:sponsoredCampaignGroup` | status, total budget, run schedule |
| Ad group | Campaign | `adCampaigns` | `urn:li:sponsoredCampaign` | targeting, budget, bid, schedule, format |
| Ad | Ad | `creatives` | `urn:li:sponsoredCreative` | the post it sponsors, its own `intendedStatus` |
| Audience | Matched audience | `dmpSegments`, then `adSegments` | `urn:li:adSegment` | the 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](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/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](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/protocol-version), a shared page dated January 2021, not on the endpoint pages that need it.

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

[Audience Counts](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/audience-counts) prints its request both ways:

`audienceCounts`:
```text
# 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`:
```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](https://github.com/adwarp-dev/adwarp/blob/main/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. 09: Each 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`:
```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`:
```bash
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`:
```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](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/account-structure/create-and-manage-campaigns) 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](https://adwarp.dev/blog/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](https://adwarp.dev/docs/safety).

*Fig. 10: The 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`:
```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. 11: Each 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`:
```bash
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](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/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](https://adwarp.dev/blog/linkedin-thought-leader-ads), 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. 12: Sponsoring 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.

### Your first draft campaign, in call order

1. `GET /adAccounts?q=search`. Stop here until your account shows up.
2. `GET /audienceCounts?q=targetingCriteriaV2&...` with the encoder above. A total of 0 means under 300 members, too few to run.
3. `POST /adAccounts/{id}/adCampaignGroups` as `DRAFT` with a `runSchedule`. Save `x-restli-id`.
4. `POST /adAccounts/{id}/adCampaigns` as `DRAFT` with `politicalIntent` and `offsiteDeliveryEnabled`. Save `x-restli-id`.
5. `POST /images?action=initializeUpload`, then upload the bytes to the URL it returns.
6. `POST /adAccounts/{id}/creatives?action=createInline` with `intendedStatus: DRAFT`.
7. Check in Campaign Manager that the ad editor shows your destination URL. Go live only after someone has looked, there or with a `PARTIAL_UPDATE` setting `status: ACTIVE`.

## 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. 13: A $set on a conversion's campaigns array replaces the list, so writing only the new campaign drops the 3 already attached.*

`pause-ad.ts`:
```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. 14: An 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](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/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](https://adwarp.dev/blog/linkedin-conversion-tracking) 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. 15: Paging 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](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/search.ts) warns about it.

`search-all.ts`:
```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](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/ads-reporting-schema) allows `timeGranularity` of `ALL`, `DAILY`, `MONTHLY` or `YEARLY`, so weekly numbers mean pulling daily rows and bucketing them yourself.

`analytics.ts`:
```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](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/ads-reporting) 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. 16: In 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](https://learn.microsoft.com/en-us/linkedin/marketing/matched-audiences/create-and-manage-list-uploads) (updated August 17, 2026) documents the flow properly, and Adwarp's [dmpSegments.ts](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/dmpSegments.ts) runs it:

*Fig. 17: Hashed 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`:
```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](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads/advertising-targeting/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. 18: After 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](https://adwarp.dev/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](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/adLibraryApi.ts), written against live responses.

*Fig. 19: The Ad Library API returns format, advertiser, targeting and impressions for ads served in the European Union, never the copy or image.*

`curl`:
```bash
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](https://adwarp.dev/linkedin-ads/ad-library) shows how it uses the results.

*Fig. 20: Page 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. 21: Most 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? |
| --- | --- | --- | --- |
| 400 `VERSION_MISSING` | No `LinkedIn-Version` header | Add it | Yes, error responses page |
| 426 `NONEXISTENT_VERSION` | Sunset version | Bump the version | Yes, error responses page |
| 403 `ACCESS_DENIED` on `/adLibrary` | Ad Library product not granted | Request the product | No reference page |
| 400 `Syntax exception in path variables` | URN in the path not encoded | Encode the whole URN | Only for test accounts, with another fix |
| 400 on an audience estimate | Old dotted `target.includedTargetingFacets` parameters | Use `q=targetingCriteriaV2` | No |
| 400 pausing a creative | Creative still in review | Wait for the review | Yes, creatives page |
| 200 with an empty account list | Account not mapped on Development | Map the account | The mapping, not the symptom |
| The same first page forever | `start=` on a `q=search` finder | Use `pageToken` | Paging yes, `start` no |
| 429 | Daily quota, infrastructure protection or the reporting cap | Back off, retry later | Yes, Retry-After no |

[Rate limits](https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/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. 22: Daily 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](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/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](https://github.com/adwarp-dev/adwarp), under the MIT license. Start with these files:

*Fig. 23: 6 files in the Adwarp repo hold working versions of every call in this post.*

| File | Shows |
| --- | --- |
| [http.ts](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/http.ts) | The 2 headers, retries on 429 and 5xx, `x-restli-id`, the raw query escape hatch |
| [targeting.ts](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/targeting.ts) | Restli encoding of `targetingCriteria` and the audience estimate |
| [search.ts](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/search.ts) | `pageToken` paging for `q=search` finders |
| [campaigns.ts](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/campaigns.ts) | The required campaign fields and `PARTIAL_UPDATE` on campaigns |
| [creatives.ts](https://github.com/adwarp-dev/adwarp/blob/main/packages/core/src/resources/creatives.ts) | Inline image ads, the `content.article` shape, encoded URNs in paths |
| [AGENTS.md](https://github.com/adwarp-dev/adwarp/blob/main/AGENTS.md) | Every 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](https://adwarp.dev/docs/tools) lists every tool, and [LinkedIn Ads](https://adwarp.dev/linkedin-ads) shows them at work. Google's version of this setup is in [Google Ads API access](https://adwarp.dev/blog/google-ads-api-access).

Put it to work: [LinkedIn Ads](https://adwarp.dev/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.

## Questions

### 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.

### 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.

### 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.

## Related

- [Google Ads API access](https://adwarp.dev/blog/google-ads-api-access): The same questions for Google Ads: who gets API access and how the OAuth setup works.
- [Ads MCP servers](https://adwarp.dev/blog/ads-mcp-servers): The servers that put LinkedIn Ads and Google Ads behind an AI client, compared.
- [Create a LinkedIn app](https://adwarp.dev/docs/linkedin-app): Step-by-step setup for the developer app, products, redirect URL and account mapping.

Canonical page: https://adwarp.dev/blog/linkedin-ads-api
Last updated: 2026-11-07
