Lesson 07 · outer fringe

Who Is Allowed

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


Three gates, one error code

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.

Gate 1 — scope, and there is no service account

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:

ScopeProgramHow you get it
rw_adsAdvertising APISelf-apply in the Developer Portal, then vetting.
r_adsAdvertising APISame. Read-only.
r_ads_reportingAdvertising APISame. Analytics reads — out of scope for now.
rw_dmp_segmentsMatched 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_conversionsConversions APIRequestable from the Developer Portal.
r_marketing_leadgen_automationLead Sync APIRequestable 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.

Gate 2 — role, and it beats scope

Five roles, strictly nested. Each one is the previous plus something.

RoleAdds
VIEWERRead 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.

The composite key

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.

Gate 3 — tier, and this is the multi-tenant gate

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.

What this means for your schema

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.


Practice

Exercise 1 · recognition

Which gate failed?

Every one of these is a 403 or an unexpected empty result. Name the gate.

Exercise 2 · execution

Onboard a tenant

A new customer clicks "Connect LinkedIn". Click the steps in order.

Exercise 3 · bug hunt

Find the bug

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

From memory

Exercise 5 · execution

The support ticket

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.


Retaining this

  1. Scope, role, tier. Three gates, one 403. Say the three words out loud now; they are the index into everything else in this lesson.
  2. Role beats scope. VIEWER + rw_ads = no writes. If you retain one asymmetry, retain that one — it is the one your support queue will be made of.
  3. Act now, not later: read the Integration Requirements and start the Standard tier conversation. Multi-tenant campaign management is the documented trigger for needing it, so it is on the critical path, alongside the rw_dmp_segments application from Lesson 04. Both are other people's calendars.
  4. Tomorrow: draw the three gates from memory, then for each one name the party who controls it and the place they control it from. If you can do that, you can debug a 403 without opening the docs.

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.