Lesson 07 · outer fringe
Three independent gates stand between your code and a customer's ad account, and all three fail with the same status code. This is the lesson that decides the shape of your multi-tenant schema.
≈15 min · pinned to li-lms-2026-08
A request succeeds only when all three of these hold. They are orthogonal, they are configured in three different places by three different parties, and a 403 does not tell you which one failed.
graph TD
R["your request"] --> G1
G1{"1. Scope
does the token carry
rw_ads?"} -->|no| F["403"]
G1 -->|yes| G2
G2{"2. Role
does the member hold a
sufficient role on this account?"} -->|no| F
G2 -->|yes| G3
G3{"3. Tier
is this account reachable
by your app?"} -->|no| F
G3 -->|yes| OK["200"]
classDef gate fill:#dbeafe,stroke:#2563eb,color:#1e3a5f
classDef bad fill:#fff1f2,stroke:#f43f5e,color:#881337
classDef good fill:#f0fdf4,stroke:#22c55e,color:#14532d
class G1,G2,G3 gate
class F bad
class OK good
Learn the three names, because you will be saying them to each other for years: scope, role, tier. Scope is what the member granted your app at OAuth time. Role is what the account granted the member, inside Campaign Manager. Tier is what LinkedIn granted your app.
The single most consequential sentence in the whole permissions surface: "All LinkedIn Marketing API Program permissions are three-legged permissions. Two-legged auth is not available for marketing use cases."
Three-legged means the token represents a person who authorised your app. There is no client-credentials flow, no service account, no app-level token. Every write you ever make is attributed to a named human, and that human's authorisation can lapse.
Design consequences, and they are load-bearing
The scopes themselves, and which program they belong to — the second column is the part that costs calendar time rather than engineering time:
| Scope | Program | How you get it |
|---|---|---|
| rw_ads | Advertising API | Self-apply in the Developer Portal, then vetting. |
| r_ads | Advertising API | Same. Read-only. |
| r_ads_reporting | Advertising API | Same. Analytics reads — out of scope for now. |
| rw_dmp_segments | Matched Audiences private program | Interest form only. LinkedIn contacts you "up to 60 days after submission only if" they see a fit. Requires existing Advertising API access. |
| rw_conversions | Conversions API | Requestable from the Developer Portal. |
| r_marketing_leadgen_automation | Lead Sync API | Requestable from the Developer Portal. Replaced the deprecated r_ads_leadgen_automation. |
Note the shape: a scope is not a feature flag you turn on, it is a product you are admitted to. Lesson 04 flagged rw_dmp_segments as a business-development dependency; here is the same fact generalised. The "up to 60 days, only if" phrasing is not boilerplate — it means no response is the expected outcome of a weak application.
Five roles, strictly nested. Each one is the previous plus something.
| Role | Adds |
|---|---|
| VIEWER | Read campaign data and reports. |
| CREATIVE_MANAGER | + create and edit ads. |
| CAMPAIGN_MANAGER | + create and edit campaigns. |
| ACCOUNT_MANAGER | + edit account data and manage user access. |
| ACCOUNT_BILLING_ADMIN | + access billing data, and is billed for this account. |
Two facts about this table that you cannot infer from it:
1. VIEWER is read-only even with rw_ads. The docs state it as a parenthetical and it is the whole point: role overrides scope. Your app can hold every write permission LinkedIn grants and still be unable to create a campaign, because the human who connected happens to be a viewer on that account. This is the most common source of "it works for my colleague and not for me" and your error surface should name it explicitly.
2. There should be exactly one ACCOUNT_BILLING_ADMIN per account. "ONLY ONE USER", in capitals. It is a cardinality constraint on a role — unusual, and it means the role is not really a permission set, it is an identity: the party being invoiced. If your product ever offers "grant admin access to a teammate", this is the one role it must not offer.
An adAccountUser has no id of its own. It is keyed by the pair, in Rest.li's tuple syntax:
PUT /rest/adAccountUsers/(account=urn:li:sponsoredAccount:516986977,user=urn:li:person:_mVMF2Kp8p)
GET /rest/adAccountUsers/(account=…,user=…)
DELETE /rest/adAccountUsers/(account=…,user=…)
// and the two finders, which are the ones you will actually use:
GET /rest/adAccountUsers?q=authenticatedUser // all accounts this person can reach
GET /rest/adAccountUsers?q=accounts&accounts={urn} // all people on one account
q=authenticatedUser is your onboarding call. Right after OAuth, it answers "which of my new customer's ad accounts am I allowed to touch, and as what" in one request — and it is the only way to discover them. You cannot list a customer's accounts any other way.
Three sharp edges on these endpoints
One more, and it shapes your UI: "when making a GET call to fetch existing users on an account, the authenticated user can only view themselves unless they're an account manager." So the same q=accounts call returns a one-element list or the full team depending on who authorised you. Not an error, not a different endpoint — a silently truncated result set.
Every app starts on Development tier. On Development tier, an ad account is only reachable if it has been added to your app by hand in the Developer Portal.
That is the answer to a 403 that survives both other gates. From the FAQ: a 403 when creating or updating an ad account "indicates a member's access token doesn't have the right scope permissions. Often, if your app is on Developer access tier, it may indicate the ad account hasn't been added to your app in Developer Portal."
The sentence that is about your product specifically
"For the Advertising API, you don't need to upgrade to Standard tier unless you plan to build a campaign management solution for multiple ad accounts."
That is precisely your mission. Multi-tenant campaign management is the definition of needing Standard tier. So Standard tier is not an optimisation for later — it is a hard prerequisite for your product existing, and it is gated on LinkedIn's Technical Sign Off against the published Integration Requirements.
Practical sequencing: build against a handful of manually-added accounts on Development tier, but read the Integration Requirements now and treat them as your acceptance criteria. They enumerate which entities and operations you are obliged to support — which means they, not your own roadmap, decide when you are done.
Pulling the three gates together into the shape of your own data:
// A connection is a PERSON's grant, not an organisation's. Several per tenant.
type LinkedInConnection = {
tenantId: TenantId
personUrn: `urn:li:person:${string}` // from GET /me — cannot be constructed
scopesGranted: Scope[] // what THIS grant carries; may differ per person
refreshToken: Encrypted
revokedAt: Date | null
}
// Reachability is a fact about (connection, account) — never about the account alone.
type AccountAccess = {
connectionId: ConnectionId
accountUrn: `urn:li:sponsoredAccount:${string}`
role: AccountUserRole // from q=authenticatedUser
observedAt: Date // roles change in Campaign Manager, silently
}
The load-bearing decision is the second table's key. If you store role on the account, you have asserted that an account has one role — but a role is a property of the pair, and two of your tenant's connected people will legitimately disagree. Getting this wrong produces a bug that only appears once a customer connects a second user, which is to say after launch.
And observedAt rather than a plain cached value, because a customer's admin can demote your connected user in Campaign Manager without any notification reaching you. Roles are effect, not intent — the same distinction as Lesson 06, one layer up.
Exercise 1 · recognition
Every one of these is a 403 or an unexpected empty result. Name the gate.
Exercise 2 · execution
A new customer clicks "Connect LinkedIn". Click the steps in order.
Exercise 3 · bug hunt
Click the offending line, or say there is no bug. One of the three is clean.
A · grant a teammate access
POST /rest/adAccountUsers/(account=urn:li:sponsoredAccount:5169,user=urn:li:person:abc) { "patch": { "$set": { "account": "urn:li:sponsoredAccount:5169", "user": "urn:li:person:abc", "role": "CAMPAIGN_MANAGER" }}}
B · discover a tenant's accounts after OAuth
const me = await get('/rest/me')const mine = await get('/rest/adAccountUsers?q=authenticatedUser')for (const el of mine.elements) { await db.accountAccess.upsert({ accountUrn: el.account, role: el.role, observedAt: now() })}
C · check before writing
const CAN_WRITE_CAMPAIGNS = new Set([ 'CAMPAIGN_MANAGER', 'ACCOUNT_MANAGER', 'ACCOUNT_BILLING_ADMIN']) function canCreateCampaign(a: AccountAccess, c: LinkedInConnection) { if (!c.scopesGranted.includes('rw_ads')) return false return CAN_WRITE_CAMPAIGNS.has(a.role)}
Exercise 4 · recall
Exercise 5 · execution
A customer writes: "Your product worked fine for months. This morning every campaign edit fails. Nothing changed on our side." List the hypotheses you would check, in the order you would check them, and say which single API call discriminates between the most of them.
Where this leaves you
Seven lessons in, you have the spine, the actors, the promotables, the targeting algebra, audiences, the delivery vocabulary, the serving diagnosis, and now authorisation. That is enough to design the multi-tenant schema in your mission — which is the natural next thing to actually do, rather than learn.
The locked nodes ahead are narrower and more mechanical: Rest.li 2.0 (the $set patch syntax you met above, batch operations, the List() encoding), versioning as a maintenance discipline, lead gen forms, and Sponsored Messaging's divergent object model. Pick by what your build hits first.
Sources: Ad Account Users (roles, composite key, finders, error codes), Account Access Controls, Marketing API FAQ (three-legged only; tiers; the 403 diagnosis; rw_dmp_segments access), Integration Requirements. Version li-lms-2026-08.