The LinkedIn Ads API, one refusal at a time LinkedIn's Marketing API diverged from its own documentation on 14 points when tested against a real ad account, according to a first-person engineering account of the platform's sixth ad integration. The account rejected a daily budget on the campaign group with a bare 500 error, required a run schedule on campaigns despite the schema marking the field optional, and refused a PAUSED status on create, while four search endpoints (adAccounts, adCampaignGroups, adCampaigns, adCreatives) moved to cursor pagination in early 2024 and reject pageSize with a 400 Invalid param. The author's stated rule is that documentation only shows what the API accepts, and only a live account reveals what it refuses. The LinkedIn Ads API, one refusal at a time Our sixth ad platform. A campaign group that holds no money, a status you cannot create, one Page that may post, and the eleven other things a real account told us. By the sixth platform we had a rule: the documentation tells you what the API accepts, and only the account tells you what it refuses. Reddit taught us that https://growomat.com/blog/reddit-ads-api-integration-lessons . ChatGPT Ads confirmed it https://growomat.com/blog/openai-ads-api-integration-lessons . So for LinkedIn we did the boring thing first: mirrored the whole Marketing API documentation locally, built the adapter against the mirror, and then pointed the entire stack at a real ad account before believing any of it. The account disagreed with the mirror on fourteen points. Here are the ones worth your afternoon. Two ways to turn a page, and each endpoint speaks only one LinkedIn moved four search endpoints to cursor pagination in early 2024: adAccounts , adCampaignGroups , adCampaigns and adCreatives . Everything else still pages the old way, with start and count . That is documented. What is not obvious is that the two grammars are not interchangeable. Send pageSize to an index-paginated finder and you do not get an ignored parameter, you get: 400 Invalid param The finder that lists which ad accounts a member can reach is index-paginated. We paginated it the cursor way, so the account list came back as an error, the connect handler correctly refused a connection whose accounts could not be read, and the first real user was told to sign in with an account that administers their ads. They already had. The second real connect hit the next one. We read the accounts with a batch get, ids=List ... , the way the organisations and posts endpoints document. The ad-accounts endpoint answers 404 No virtual resource found , which is Rest.li for "this resource has no such method". So one platform hands you three ways to ask for a list, chosen per endpoint, and the wrong one is a refusal rather than a fallback. Lesson: read the pagination grammar off each endpoint's own page, and never let a helper assume one style for a whole API. Four tiers, and the top one holds no money LinkedIn has one more level than everyone else: account, campaign group, campaign, creative. Our campaign is their group, our ad group is their campaign, and "campaign" in any LinkedIn field name means our ad group. The schema documents a dailyBudget on the campaign group and a budget optimisation mode that shares it across the campaigns underneath. That is the canonical home for a budget, so that is where we put it. The account answered with a bare 500 and no body at all. No message, no field name, nothing. The only way through a bare 500 is to start from the vendor's own worked example, which creates fine, and add one field at a time until it breaks. Any daily budget on a group breaks it, in every combination we tried. Ask for the shared-budget mode and you get a proper 400 instead, demanding a total budget, which then has a floor of one hundred in the account's currency and needs an end date. None of that is in the group's documentation. So the group carries no money. The campaign below it carries the daily budget, and a daily budget of five euros was accepted there, so there is no floor to declare. The same campaign also refuses to exist without a run schedule: 422 field is required but not found and has no default value Its schema table marks the field optional. The API does not. Lesson: errorDetails , the one field that names the offending parameter, only appears on a 400. When a write 500s with nothing attached, bisect from the documented example upward. There is no faster way. Paused is not a status you can create Every platform we integrate lands a first push paused, so nothing spends before the user has looked at it. LinkedIn accepts a status on create, so we sent PAUSED : 400 /CampaignGroup/status cannot be changed from null to PAUSED Only ACTIVE and DRAFT are creatable. Draft is no use, because a campaign under a draft group cannot be paused either. So the group is created active and paused with the very next request, before anything exists under it that could serve, and a pause that fails is refused by id rather than reported as paused. The creative is stricter still: 400 /Creative/status transition is not allowed from null to PAUSED if /Creative/review/reviewStatus is not set to APPROVED You cannot pause a creative until review has approved it, and review does not start until the creative leaves draft. Under review it can be moved neither to paused nor back to draft. So on this tier the parked state our "paused" means is DRAFT , and a draft comes back to us as paused so the next sync does not see a disagreement and try to pause it again, which would be refused every time. One more thing about drafts. Delete one, the API answers 204, and the creative reads back as PENDING DELETION . Nothing is ever gone on LinkedIn; it walks down a ladder and stays listed. Our test account holds one campaign more after every run, all of them tombstones. Only one Page may post, and it is not the one you chose A Sponsored Content post is authored as a Company Page, so our connection screen let the user choose one of the Pages they administer. The account refused the campaign the post would run under: 400 Value of /Campaign/associatedEntity expected to match value urn:li:company:… of /Account/reference Every ad account belongs to one organisation, and that organisation is the only author a post under the account may have. LinkedIn fills it in itself when you send nothing, and refuses anything else. The Page the user had chosen was not a valid answer, and there was never more than one valid answer. So the choice has one option and makes itself, and what the connection actually checks is whether the member holds a role that may sponsor content as that Page. That is either the administrator role or a role called the Direct Sponsored Content Poster, and reading only administrators told a poster their own Page was missing. This changed the creative shape too. We had been deciding whether an ad ships as a feed post or a right-rail text ad by asking "is there an image and a Page", and the Page turned out to decide nothing. The shape now follows the LinkedIn campaign type the ad group chose, because a creative must match its campaign's type, and a feed post without an image is refused by name rather than quietly turned into a text ad nobody asked for. The image exists before it is usable Uploading an image is three calls: initialise, put the bytes, then reference the asset from the post. Do the third straight after the second and you get: 404 Could not find entity The asset is there. It reads as WAITING UPLOAD , then PROCESSING , and only once it reads AVAILABLE may a post point at it. We poll for that now, with a bound, and name a failed processing instead of retrying it. The create that lands the post and the creative in one call has its own surprise. The documentation promises a 201 with the id in the x-restli-id header, like every other create on the platform. This one answers 200 with the id in the body and no header, and the post's own id is in neither, so the creative is read back once to find it. A text ad bids by hand Our first automatic-bidding campaign under a text ad was refused with a sentence that lists every field it did not like: 400 ObjectiveType WEBSITE VISIT, CampaignFormat TEXT AD, CampaignType TEXT AD, OptimizationTargetType MAX CLICK, CostType CPM … is not a valid CampaignConfiguration The right rail has no automatic bidding. The combination tables in the documentation say so, if you read the TEXT rows rather than the STANDARD UPDATE ones: manual cost per click or manual cost per thousand, and nothing else. The Audience Network is disallowed there too. An automatic strategy under a text-ad ad group now becomes that ad group's own click bid, and the export says so before the push instead of letting the platform say it after. A post is immutable, so an edit is a new ad A LinkedIn creative is its post, and a post cannot be edited. Change the copy and the adapter creates a replacement creative under a new id and retires the old one. That much we had designed for. What we had not designed for was our own pipeline, which learned this pattern on Reddit, where the ad keeps its id and only the post behind it is swapped. It moved the post id and left the ad id alone. On LinkedIn the ad id is the thing that changed, so after the first edit every later edit would have patched a tombstone. It moves the right id now. Conversions are two products with two permissions This one cost the most and was the most worth having. Counting a result on LinkedIn is the Insight Tag: create the tag once per account, create a rule per result, associate a campaign, read results back. All of that runs on the ordinary advertising permission. Streaming events to LinkedIn from your own servers is a separate product behind a separate review, with its own permission, and a rule created for one is not addressable by the other. Then there is what a rule fires on. Give it a URL expression and it counts page views that match. Give it none and it is event-specific, which means the tag counts nothing until the page after the moment calls: window.lintrk 'track', { conversion id: … } Every rule our product creates is event-specific, because our conversion model carries no page rule. So the install a customer must paste is three things, not one: the tag in the head, a noscript pixel after the body, and that call on the thank-you page. We were handing back the first and dropping it on the floor, and the third did not exist. The tag would have loaded and counted nothing, on every account, forever. Lesson: when a platform says "conversions", find out how many products it means, which permission each one needs, and what actually causes a count. The mock agreed with us, again Our mock replaced the account listing wholesale, so no test ever built the query string the platform refused. It claimed every conversion rule it created was streamable, so the event sink was proven against rules the platform never attributes through it. It accepted an automatic bid on a text ad and a paused creative on create. It refuses all of those now, in LinkedIn's own words, with the date beside each rule. And the conversion contract that a manifest declares is now held by the conformance suite: a platform that says it can count a result has to map one, create one, and hand back an install a customer can read, or the build fails. Where we landed The live harness that found all of this is the one we built after Reddit, and still nothing in it names a platform. It reads each platform's own declaration of what it ships, and LinkedIn taught it two new questions: what the platform's own word for "removed" is, and which creative shapes an ad group can ask for. It now builds one campaign with one ad group per shipped shape, a feed post and a right-rail text ad, pushes both, reads both back, and tears them down. It also drives the conversion loop end to end: a result created through our public API lands as a rule on the real account and comes back with all three things to install. Twenty-seven checks pass against the real account, and the three that skip say why. Creating and shipping a LinkedIn campaign in Growomat now looks exactly like the other five platforms: build it, validate it, push it, and it lands parked for you to enable. None of the fourteen refusals above reach you. They are encoded in the adapter, in the checks that run before an export, and in a mock that refuses the way the platform does. The documentation mirror took an afternoon. The account took the week. That is still the right order. Run all four platforms from one place. Build, optimize and report on Google, Microsoft, Reddit and Meta campaigns without an agency retainer. See what it costs https://growomat.com/pricing Read next What the ChatGPT Ads API does differently https://growomat.com/blog/openai-ads-api-integration-lessons Our fifth ad platform. Here are the refusals a real ChatGPT Ads account sent back, why the documentation did not warn us, and what each one means for you. engineering Why the Reddit Ads API was harder than it looks https://growomat.com/blog/reddit-ads-api-integration-lessons We budgeted a day to integrate Reddit Ads and it took two weeks. Here are the refusals we hit, why our tests never caught them, and how it ends.