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. ChatGPT Ads confirmed it. 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.
d is not a status you can create #
Every platform we integrate lands a first push d, so nothing spends before
the user has looked at it. LinkedIn accepts a status on create, so we sent
D:
400 /CampaignGroup/status cannot be changed from null to D
Only ACTIVE and DRAFT are creatable. Draft is no use, because a campaign
under a draft group cannot be d either. So the group is created active and
d with the very next request, before anything exists under it that could
serve, and a that fails is refused by id rather than reported as d.
The creative is stricter still:
400 /Creative/status transition is not allowed from null to D
if /Creative/review/reviewStatus is not set to APPROVED
You cannot 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
d nor back to draft. So on this tier the parked state our "d" means
is DRAFT, and a draft comes back to us as d so the next sync does not
see a disagreement and try to 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 #
Up 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 d 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.
Read next
What the ChatGPT Ads API does differently
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
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.