Lesson 01 · inner fringe

The Spend Spine

Four objects, their exact names, and the one cardinality rule that will bite your schema if you get it wrong.

≈12 min · pinned to li-lms-2026-08


Two graphs, not one

Almost every confusion in the LinkedIn Ads API comes from collapsing two separate structures into one mental picture. There is a spend spine — the strictly hierarchical chain that answers whose money, how much, aimed at whom. And there is a content graph — the loose, cross-referenced web of posts, videos, events, and organization pages that answers what actually appears on screen, and who is seen to have said it.

The spine is a tree. The content graph is not. They meet at exactly one field, on the lowest node of the spine. This lesson is the spine only; the content graph is Lesson 02.

The four levels

Learn all three columns together. In this API the human name, the REST resource, and the URN namespace are all different strings, and you will need each in a different place — the resource name in your HTTP client, the URN in every payload, the human name in conversation with a customer.

Human name REST resource URN namespace Owns
Ad Account/adAccountsurn:li:sponsoredAccountCurrency, billing, who may touch it, which entity it advertises for
Campaign Group/adCampaignGroupsurn:li:sponsoredCampaignGroupA shared status, budget and schedule ceiling over related campaigns
Campaign/adCampaignsurn:li:sponsoredCampaignAlmost everything: objective, targeting, budget, bid, schedule, locale, format, placement
Creative/adCreativesurn:li:sponsoredCreativeA pointer to the thing to render, plus a call-to-action and a review verdict

Note the prefix asymmetry: the resources are ad*, the URNs are sponsored*. This is not a typo in the docs — it is two generations of naming that were never reconciled. Bake both into your constants file and stop thinking about it.

Nesting in the URL, too

The child resources are nested under the account, so the account ID appears twice in a typical write — once in the path, once implicitly as the parent:

POST /rest/adAccounts/{accountId}/adCampaignGroups
POST /rest/adAccounts/{accountId}/adCampaigns
POST /rest/adAccounts/{accountId}/adCreatives

URN grammar

A URN (Uniform Resource Name) is LinkedIn's universal typed pointer. Three colon-separated parts after the scheme:

urn:li:sponsoredCampaign:123456789
 │   │        │              └─ opaque id, assigned by LinkedIn
 │   │        └─ namespace = the type
 │   └─ namespace authority: LinkedIn
 └─ scheme

The payoff is that the type travels with the reference. A field typed as "a URN" can accept a heterogeneous union, and the server can dispatch on the namespace. You will see exactly this in Lesson 02, where one content field accepts six different namespaces.

Correcting your calibration answer

You reasoned that URNs remove the need for an incrementing counter and might let the client generate IDs. Half right, and the half that's wrong matters. The namespace does remove the need for a single global ID space — sponsoredCampaign:5 and organization:5 coexist without collision. But LinkedIn always mints the ID server-side. On create you get it back in the x-restli-id response header, not in the body. There is no client-supplied idempotency key, which is precisely why your integration needs its own request-dedupe layer.

The Meta false friend

You already think in Meta's model. Keep it — but the word Campaign denotes a different level in each system, and that is the single most expensive confusion available to you here.

MetaNearest LinkedInWhere the analogy breaks
Ad AccountAd AccountClean match. LinkedIn adds a hard ceiling: 5,000 campaigns and 15,000 creatives per account.
Campaign
(holds the objective)
Campaign GroupA group holds no objective and no targeting. It is a budget-and-status envelope, nothing more.
Ad Set
(budget, targeting, schedule)
CampaignLinkedIn's Campaign carries the Ad Set's job and the objective — objectiveType lives here, one level lower than on Meta.
AdCreativeA Meta Ad wraps a creative. A LinkedIn Creative is the ad-level object, and it mostly just points elsewhere.
Lookalike AudienceLinkedIn's analogue is Audience Expansion (a boolean on the campaign) plus Matched Audiences. Different enough that the word will mislead you.

Read the middle two rows as a shift by one level. Everything Meta puts on the Ad Set, LinkedIn puts on the Campaign — and then piles the objective on top of it. That is why the LinkedIn Campaign schema is enormous and the Campaign Group schema is tiny.

graph TD
  subgraph M["Meta"]
    MA["Ad Account"] --> MC["Campaign
objective"] --> MS["Ad Set
budget · targeting · schedule"] --> MD["Ad"] end subgraph L["LinkedIn"] LA["Ad Account
currency · billing · reference"] --> LG["Campaign Group
budget ceiling · status"] --> LC["Campaign
objective · budget · targeting
schedule · bid · format
"] --> LR["Creative
pointer + CTA"] end MC -.->|"same name,
different level"| LC classDef m fill:#e0e7ff,stroke:#4338ca,color:#1e1b4b classDef l fill:#dbeafe,stroke:#1d4ed8,color:#172554 classDef big fill:#fef3c7,stroke:#d97706,stroke-width:3px,color:#78350f class MA,MC,MS,MD m class LA,LG,LR l class LC big

The cardinality rule

In the Creative schema, the campaign field is required and singular. A creative belongs to exactly one campaign, forever.

Correcting your calibration answer

You put targeting on the campaign — correct — but justified it with "the same creative can belong to multiple campaigns with different targets." That is false here. Reusing one image across three audiences means three separate creatives, each pointing at the same underlying post. The post is shared; the creative is not. If you model this as a many-to-many join table you will spend a week finding out why.

Cost of the mistake: you would silently break per-creative reporting, since analytics keys on sponsoredCreative. Two campaigns "sharing" a creative could never have separate numbers.

So the whole spine is one-to-many at every joint, and the joins are named after the parent:

type Creative = {
  campaign: SponsoredCampaignUrn   // required, singular, immutable in practice
  account:  SponsoredAccountUrn    // read-only, derived from the campaign
  content?: ContentUrn             // → Lesson 02
}
type Campaign = {
  account:       SponsoredAccountUrn        // immutable once set
  campaignGroup: SponsoredCampaignGroupUrn  // required since Oct 2020
}

Notice that the creative carries a redundant account — read-only, denormalised for query convenience. Your own schema should derive it, not store it, or you will eventually have two sources of truth that disagree.

The default group trap

A campaign group is created automatically when an ad account is created. It is easy to build an integration that never creates a group and quietly dumps everything into the default one. That works — until you hit the limit: 2,000 campaigns per non-default group, against 5,000 per account. Groups are unlimited in number, so create them deliberately.


Practice

Do these from memory. Scrolling up first feels productive and teaches you almost nothing — the retrieval effort is the learning.

Exercise 1 · recall

The URN namespaces

Type the namespace for each level. The urn:li: prefix is optional.

Ad Accounturn:li:
Campaign Groupurn:li:
Campaignurn:li:
Creativeurn:li:

Careful: two of these are prefixes of two others. That is exactly why a substring check in your URN parser is a bug.

Exercise 2 · bug hunt

Click the broken line

Three payloads. Click the one line in each that the API will reject. One of the three is correct — if you think so, click "No bug here".

A · POST /rest/adAccounts/{id}/adCreatives

{  "campaigns": ["urn:li:sponsoredCampaign:301", "urn:li:sponsoredCampaign:302"],  "content": "urn:li:ugcPost:7099",  "intendedStatus": "DRAFT",  "name": "Q3 webinar — DACH"}

B · POST /rest/adAccounts/{id}/adCampaigns

{  "account": "urn:li:organization:2414183",  "campaignGroup": "urn:li:sponsoredCampaignGroup:6088",  "type": "SPONSORED_UPDATES",  "costType": "CPC",  "name": "CTO outreach"}

C · POST /rest/adAccounts/{id}/adCreatives

{  "campaign": "urn:li:sponsoredCampaign:301",  "content": "urn:li:share:6844",  "intendedStatus": "ACTIVE",  "name": "Q3 webinar — variant b"}

Exercise 3 · execution

Order the calls

A new customer has just completed OAuth. They want one image ad live. Click the steps in the order you must perform them — each one needs an ID that a previous one returned.

Exercise 4 · interleaved translation

Meta → LinkedIn, under pressure

Give the LinkedIn level that does each job. Answer with a level name from the spine — or none if no single level owns it.

Meta's Ad Set maps to LinkedIn's…
Meta's Campaign maps to LinkedIn's…
The level that holds objectiveType
The level that holds currency
The level that holds targetingCriteria
The LinkedIn equivalent of a Lookalike Audience

Three of the six answers are the same word. That is the point — LinkedIn's Campaign is where nearly all configuration lives.


Retaining this

Three things, in descending order of value:

  1. Write the constants file today. Before you read Lesson 02, put the four resource paths and four URN namespaces into your codebase as typed constants. Producing them is a retrieval act, and it puts the vocabulary where you will re-read it a hundred times.
  2. Say the shift-by-one out loud. "Meta Ad Set is LinkedIn Campaign. Meta Campaign is LinkedIn Campaign Group." You have a fluent competing model; the interference is real and will resurface in a design meeting.
  3. Tomorrow, before opening any docs, redraw the spine from memory with one field per level. Then check. The gap between what you recall cold and what you recognised here is the honest measure of what stuck.

Next, on the outer fringe

Lesson 02 — Acting entities and the content graph: who can be an advertiser (reference), who the ad is for (associatedEntity), who appears to have said it (contentAuthor), and the six-way union that field content accepts. It answers the second half of your original question directly.

Every claim here traces to Create and Manage Creatives, Create and Manage Campaigns, and Create and Manage Ad Accounts, version li-lms-2026-08.