{"slug": "the-linkedin-ads-api-one-refusal-at-a-time", "title": "The LinkedIn Ads API, one refusal at a time", "summary": "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.", "body_md": "# The LinkedIn Ads API, one refusal at a time\n\nOur 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.\n\n**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.**\n\n[Reddit taught us that](https://growomat.com/blog/reddit-ads-api-integration-lessons). [ChatGPT Ads\nconfirmed it](https://growomat.com/blog/openai-ads-api-integration-lessons). So for LinkedIn we did\nthe boring thing first: mirrored the whole Marketing API documentation locally,\nbuilt the adapter against the mirror, and then pointed the entire stack at a\nreal ad account before believing any of it.\n\nThe account disagreed with the mirror on fourteen points. Here are the ones worth your afternoon.\n\n## Two ways to turn a page, and each endpoint speaks only one\n\nLinkedIn moved four search endpoints to cursor pagination in early 2024:\n`adAccounts`, `adCampaignGroups`, `adCampaigns` and `adCreatives`. Everything\nelse still pages the old way, with `start` and `count`. That is documented. What\nis not obvious is that the two grammars are not interchangeable. Send\n`pageSize` to an index-paginated finder and you do not get an ignored\nparameter, you get:\n\n```\n400 Invalid param\n```\n\nThe 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.\n\nThe second real connect hit the next one. We read the accounts with a batch\nget, `ids=List(...)`, the way the organisations and posts endpoints document.\nThe ad-accounts endpoint answers `404 No virtual resource found`, which is\nRest.li for \"this resource has no such method\". So one platform hands you three\nways to ask for a list, chosen per endpoint, and the wrong one is a refusal\nrather than a fallback.\n\nLesson: read the pagination grammar off each endpoint's own page, and never let a helper assume one style for a whole API.\n\n## Four tiers, and the top one holds no money\n\nLinkedIn 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.\n\nThe schema documents a `dailyBudget` on the campaign group and a budget\noptimisation mode that shares it across the campaigns underneath. That is the\ncanonical home for a budget, so that is where we put it. The account answered\nwith a bare 500 and no body at all. No message, no field name, nothing.\n\nThe 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.\n\nSo 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:\n\n```\n422 field is required but not found and has no default value\n```\n\nIts schema table marks the field optional. The API does not.\n\nLesson: `errorDetails`, the one field that names the offending parameter, only\nappears on a 400. When a write 500s with nothing attached, bisect from the\ndocumented example upward. There is no faster way.\n\n## Paused is not a status you can create\n\nEvery platform we integrate lands a first push paused, so nothing spends before\nthe user has looked at it. LinkedIn accepts a status on create, so we sent\n`PAUSED`:\n\n```\n400 /CampaignGroup/status cannot be changed from null to PAUSED\n```\n\nOnly `ACTIVE` and `DRAFT` are creatable. Draft is no use, because a campaign\nunder a draft group cannot be paused either. So the group is created active and\npaused with the very next request, before anything exists under it that could\nserve, and a pause that fails is refused by id rather than reported as paused.\n\nThe creative is stricter still:\n\n```\n400 /Creative/status transition is not allowed from null to PAUSED\n    if /Creative/review/reviewStatus is not set to APPROVED\n```\n\nYou cannot pause a creative until review has approved it, and review does not\nstart until the creative leaves draft. Under review it can be moved neither to\npaused nor back to draft. So on this tier the parked state our \"paused\" means\nis `DRAFT`, and a draft comes back to us as paused so the next sync does not\nsee a disagreement and try to pause it again, which would be refused every\ntime.\n\nOne more thing about drafts. Delete one, the API answers 204, and the creative\nreads back as `PENDING_DELETION`. Nothing is ever gone on LinkedIn; it walks\ndown a ladder and stays listed. Our test account holds one campaign more after\nevery run, all of them tombstones.\n\n## Only one Page may post, and it is not the one you chose\n\nA 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:\n\n```\n400 Value of /Campaign/associatedEntity expected to match\n    value urn:li:company:… of /Account/reference\n```\n\nEvery 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.\n\nSo 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.\n\nThis 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.\n\n## The image exists before it is usable\n\nUploading 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:\n\n```\n404 Could not find entity\n```\n\nThe asset is there. It reads as `WAITING_UPLOAD`, then `PROCESSING`, and only\nonce it reads `AVAILABLE` may a post point at it. We poll for that now, with a\nbound, and name a failed processing instead of retrying it.\n\nThe create that lands the post and the creative in one call has its own\nsurprise. The documentation promises a 201 with the id in the `x-restli-id`\nheader, like every other create on the platform. This one answers 200 with the\nid in the body and no header, and the post's own id is in neither, so the\ncreative is read back once to find it.\n\n## A text ad bids by hand\n\nOur first automatic-bidding campaign under a text ad was refused with a sentence that lists every field it did not like:\n\n```\n400 ObjectiveType WEBSITE_VISIT, CampaignFormat TEXT_AD, CampaignType TEXT_AD,\n    OptimizationTargetType MAX_CLICK, CostType CPM … is not a valid\n    CampaignConfiguration\n```\n\nThe right rail has no automatic bidding. The combination tables in the\ndocumentation say so, if you read the `TEXT` rows rather than the\n`STANDARD_UPDATE` ones: manual cost per click or manual cost per thousand, and\nnothing else. The Audience Network is disallowed there too. An automatic\nstrategy under a text-ad ad group now becomes that ad group's own click bid,\nand the export says so before the push instead of letting the platform say it\nafter.\n\n## A post is immutable, so an edit is a new ad\n\nA 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.\n\nWhat 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.\n\n## Conversions are two products with two permissions\n\nThis one cost the most and was the most worth having.\n\nCounting 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.\n\nThen 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:\n\n```\nwindow.lintrk('track', { conversion_id: … })\n```\n\nEvery 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.\n\nLesson: when a platform says \"conversions\", find out how many products it means, which permission each one needs, and what actually causes a count.\n\n## The mock agreed with us, again\n\nOur 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.\n\nIt 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.\n\n## Where we landed\n\nThe 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.\n\nIt 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.\n\nCreating 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.\n\nThe documentation mirror took an afternoon. The account took the week. That is still the right order.\n\n## Run all four platforms from one place.\n\nBuild, optimize and report on Google, Microsoft, Reddit and Meta campaigns without an agency retainer.\n\n[See what it costs](https://growomat.com/pricing)\n\nRead next\n\n### [What the ChatGPT Ads API does differently](https://growomat.com/blog/openai-ads-api-integration-lessons)\n\nOur 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.\n\nengineering\n\n### [Why the Reddit Ads API was harder than it looks](https://growomat.com/blog/reddit-ads-api-integration-lessons)\n\nWe 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.", "url": "https://wpnews.pro/news/the-linkedin-ads-api-one-refusal-at-a-time", "canonical_source": "https://growomat.com/blog/linkedin-ads-api-integration-lessons", "published_at": "2026-09-15 00:00:00+00:00", "updated_at": "2026-09-15 18:18:52.981820+00:00", "lang": "en", "topics": ["ai-products", "developer-tools"], "entities": ["LinkedIn", "LinkedIn Ads API", "LinkedIn Marketing API", "ChatGPT Ads", "Reddit"], "alternates": {"html": "https://wpnews.pro/news/the-linkedin-ads-api-one-refusal-at-a-time", "markdown": "https://wpnews.pro/news/the-linkedin-ads-api-one-refusal-at-a-time.md", "text": "https://wpnews.pro/news/the-linkedin-ads-api-one-refusal-at-a-time.txt", "jsonld": "https://wpnews.pro/news/the-linkedin-ads-api-one-refusal-at-a-time.jsonld"}}