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.
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.
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.
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 |
|---|---|---|---|---|
| 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. 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.
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.
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.
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.
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):
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.
| 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 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.
Audience Counts prints its request both ways:
# 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:
// 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].totalReporting 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 getSyntax 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.
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.
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 -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: 600000001const { 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.
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.
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.
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:800000001The 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.
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.
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" } } },
});- A conversion's
campaignsfield replaces the whole array. Read the conversion, append your campaign URN, and write the full list back (associatedCampaignsis read-only). Conversion tracking does say this: "The update replaces the whole field, rather than appending the new updated values." It also documentscampaignConversions, which links 1 campaign to 1 conversion with aPUTand skips the array. The conversion tracking guide covers the rest. - 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,nameand 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. - 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
ACTIVEcreative stays dark while its campaign isn'tACTIVE.
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."
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.
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.
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.
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:
POST /dmpSegments?action=generateUploadUrlwith your account asowner. The signed URL comes back invalue.- Upload the CSV (comma-separated values) file to that URL. The media ID is in the
locationresponse header. POST /dmpSegmentswithsourcePlatform: LIST_UPLOADandtype: USER_LIST_UPLOADfor contacts orCOMPANY_LIST_UPLOADfor companies.POST /dmpSegments/{id}/listUploadswith the media URN asinputFile.- Poll
GET /dmpSegments/{id}until a destination readsREADY. ItsdestinationSegmentIdis theurn:li:adSegmentyou put underurn:li:adTargetingFacet:audienceMatchingSegmentsin a campaign's targeting.
Emails go up as SHA-256 hex of the lowercased address with whitespace removed:
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.
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.
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 asSPONSORED_STATUS_UPDATEadvertiser, withadvertiserName,adPayerandadvertiserUrladTargeting, facets with included and excluded segments, empty ones includedadStatistics, only for ads served in the European Union: first and latest impression dates,totalImpressionsas 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.
Errors and rate limits
The last column says whether Microsoft Learn warns you about each one.
| 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 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.
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:
| File | Shows |
|---|---|
| http.ts | The 2 headers, retries on 429 and 5xx, x-restli-id, the raw query escape hatch |
| targeting.ts | Restli encoding of targetingCriteria and the audience estimate |
| search.ts | pageToken paging for q=search finders |
| campaigns.ts | The required campaign fields and PARTIAL_UPDATE on campaigns |
| creatives.ts | Inline image ads, the content.article shape, encoded URNs in paths |
| 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 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-adsBy Stan Rymkiewicz, maintainer of adwarp. Published November 7, 2026.