← back to Marketing Command Center

docs/submissions/linkedin-app-review.md

254 lines

# LinkedIn Community Management API — access package (Designer Wallcoverings)

**Purpose:** get the Marketing Command Center (MCC) able to post organic content to the
**Designer Wallcoverings LinkedIn Company Page** (`w_organization_social`).

**Honest framing up front — this is a NEW-APP + LinkedIn-REVIEW cycle, not a same-session
finish.** Steps 1–3 (create the dedicated app + request the product + verify the business email)
are driveable now. Then LinkedIn reviews the access request and emails a decision, typically
**1–3 business days**. Only after that email arrives can Steps in §4 (enable scope → mint token →
deploy → verify a real post) run. Budget two work sessions separated by the review wait.

---

## ⛔ BLOCKER FOUND 2026-07-21 — the "new app = CMA-only" plan does NOT work as written

Created the dedicated app **`DW Marketing – Community`** (app id `257140099`, **Client ID
`86z1h5ic86lw65`**, **Company verified** to the DW Page automatically — Steve created it as a
Page admin). But on the new app's **Products** tab, LinkedIn had **auto-provisioned three default
products** — **Share on LinkedIn**, **Sign In with LinkedIn (OpenID Connect)**, **Verified on
LinkedIn** — and there is **NO UI to remove them** (product cards expose only "View docs" /
"Endpoints"; no remove/manage control anywhere). That greys out CMA's **Request access**, tooltip
verbatim:

> "This API product requires that it be the only product on the application for legal and security
> reasons. This product cannot be requested because there are currently other provisioned products
> or other pending product requests. A new developer application can be created to request this product."

So a brand-new dedicated app hits the **exact same wall** as the old one — creating yet another app
won't help if it too auto-gets the defaults. **Self-serve CMA request is blocked.** Realistic paths
(need Steve): (a) **LinkedIn API Partner Support** ticket to strip the default products OR grant CMA
directly (channel: Microsoft Learn "LinkedIn API Partner Support Guide"); (b) confirm whether an app
created *without* a Company Page association avoids the defaults (but CMA requires a verified Page —
likely circular). **LinkedIn Developer Support ticket FILED 2026-07-21 → Case `CAS-11523819`** (form: linkedin.com/help/linkedin/ask/dsapi, Form Type=Developer Support, API Product=Community Management, Access Issue; requested removal of the auto-added defaults from app 257140099 OR a direct CMA Development-Tier grant; LinkedIn follows up via the registered business email). Until this is resolved, Steps 1–4 below cannot proceed. **Captured for the eventual
finish:** Client ID `86z1h5ic86lw65`; DW Company Page org id **3090253** → `LINKEDIN_ORG_URN=urn:li:organization:3090253`.

## 0. Why a NEW app (the ORIGINAL plan — superseded by the blocker above)

Posting to the DW **Company Page** requires the `w_organization_social` scope, which is only
granted by the **Community Management API** product. LinkedIn enforces a hard policy:

> Community Management API **requires it be the ONLY product on the application** (legal/security).
> *"A new developer application can be created to request this product."*

The existing app **"For Claude - 5-6-26"** (client `86d1oaphl1brc3`, app id `235220450`,
company-verified to the DW Page May-6) already has **"Share on LinkedIn"** provisioned. So its
Community Management API **"Request access" button is permanently greyed** — you cannot add CMA to
an app that already carries another product.

**Resolution = create a brand-new dedicated LinkedIn developer app that has ONLY the Community
Management API product**, tied to the Designer Wallcoverings Page. That is what this package sets up.

> Note: "Share on LinkedIn" (`w_member_social`) posts to a **personal profile**, not the brand
> Page — it is NOT what we want and must NOT be added to the new app (adding it re-triggers the
> exact greyed-out blocker above).

---

## 1. Create the new app

**Click-path:** https://www.linkedin.com/developers/apps/new
(sign in as a Designer Wallcoverings Page **admin** — the same identity that admins the DW Company
Page, so the app can be verified against it.)

Fill exactly:

| Field | Value |
|---|---|
| **App name** | `DW Marketing – Community` |
| **LinkedIn Page** | `Designer Wallcoverings` (type it, select the real DW Company Page — this associates the app with the Page and drives company verification) |
| **App logo** | Upload the DW square logo (the same mark used on the DW Page avatar — a PNG ≥ 100×100). |
| **Privacy policy URL** | `https://designerwallcoverings.com/policies/privacy-policy` |
| **Legal agreement** | ✅ check "I have read and agree to these terms" — **STEVE must click this** (legal acceptance, gated). |

Click **Create app**.

After creation, on the app's **Settings** tab, confirm the DW Page shows a **"Verify"** state and
complete company verification if prompted (LinkedIn sends a verification link to a DW Page admin —
one click confirms the app legitimately represents the DW Page). The new app gets its **own**
client id + client secret (they will differ from the old `86d1oaphl1brc3`) — note both; the secret
routes to `secrets` as the app's `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET` for the new app.

---

## 2. Add ONLY the Community Management API (+ business-email verification)

On the new app → **Products** tab:

1. Find **Community Management API** → click **Request access**.
2. **Do NOT add "Share on LinkedIn" or any other product.** CMA must be the *sole* product or the
   request will be blocked exactly as it was on the old app.
3. LinkedIn requires a **verified business email** before it will accept the request. In the
   verification sub-flow:
   - Enter **`info@designerwallcoverings.com`**.
   - LinkedIn emails a **6-digit code** to that inbox.
   - **George reads the code** (Gmail — the info@ catch-all forwards into the George Gmail;
     search subject like *"LinkedIn"* / *"verification code"*). A code `977385` was seen earlier
     this session but is **almost certainly expired** — request a fresh one and read the newest.
   - Enter the fresh 6-digit code to verify the business email.
4. Submit the access request. The button goes **greyed** = the request is **lodged and pending
   LinkedIn review**. LinkedIn **emails a decision** (approve/deny), typically **1–3 business days**.

If a **justification / use-case free-text form** appears during the request, paste §3 verbatim.

---

## 3. Access-request justification (paste-ready)

> **Designer Wallcoverings** is a luxury wallcovering and interior-design retailer. We use the
> LinkedIn Community Management API **solely to publish our own first-party organic content**
> — new product launches, design collections, and editorial imagery — **to our own Designer
> Wallcoverings LinkedIn Company Page**, for the interior-design and architecture trade audience
> that follows us there.
>
> The integration posts **only** to the single Company Page we own and administer. We do **not**
> manage, moderate, or post to any third-party Pages, we do **not** read or aggregate other
> organizations' content, and we do **not** offer this as a service to other businesses. This is a
> single-tenant, owned-Page, organic-posting use case: our marketing tool authenticates as a DW
> Page admin and publishes DW's own posts on DW's own Page. No third-party page management.

---

## 4. Post-approval finish (only after LinkedIn's approval email lands — ≈ 5–10 min)

All of these are on the **new** app unless noted. Use the **new app's** client id/secret.

### 4a. Enable the scope + redirect (Auth tab)
- App → **Auth** tab → **OAuth 2.0 scopes** → enable **`w_organization_social`**.
  (Community Management approval unlocks it in the scope list; before approval it isn't selectable.)
- App → **Auth** tab → **Authorized redirect URLs** → add exactly:
  ```
  https://marketing.designerwallcoverings.com/api/channels/oauth/linkedin/callback
  ```
  (This is the MCC's live LinkedIn callback — it must match the `redirect_uri` used at mint time
  byte-for-byte or the exchange 400s with `redirect_uri_mismatch`.)

### 4b. Mint the 60-day token (Steve clicks Allow as DW Page admin)
Build the authorize URL with the **NEW app's** client id:
```
https://www.linkedin.com/oauth/v2/authorization?response_type=code
  &client_id=<NEW_APP_CLIENT_ID>
  &redirect_uri=https%3A%2F%2Fmarketing.designerwallcoverings.com%2Fapi%2Fchannels%2Foauth%2Flinkedin%2Fcallback
  &scope=w_organization_social
  &state=dwmcc
```
Steve opens it **signed in as a DW Company Page admin**, clicks **Allow**. LinkedIn redirects to
the MCC callback with `?code=...`. Exchange the code for the access token (60-day):
```sh
curl -s -X POST https://www.linkedin.com/oauth/v2/accessToken \
  -d grant_type=authorization_code \
  -d code='<CODE_FROM_CALLBACK>' \
  -d redirect_uri='https://marketing.designerwallcoverings.com/api/channels/oauth/linkedin/callback' \
  -d client_id='<NEW_APP_CLIENT_ID>' \
  -d client_secret='<NEW_APP_CLIENT_SECRET>'
# → { "access_token": "...", "expires_in": 5184000, ... }   (5184000s = 60 days)
```
That `access_token` is `LINKEDIN_ACCESS_TOKEN`.

### 4c. Find `LINKEDIN_ORG_URN`
The MCC adapter (`modules/channels/index.js` → `postLinkedIn`) posts as
`env('LINKEDIN_ORG_URN')`, which must be **`urn:li:organization:<DW page numeric id>`**.
Query the organizations this token can act as (send the version + protocol headers the adapter
uses):
```sh
curl -s 'https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED' \
  -H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
  -H 'LinkedIn-Version: 202401' \
  -H 'X-Restli-Protocol-Version: 2.0.0'
# → elements[].organization = "urn:li:organization:<NUMERIC_ID>"  ← that's LINKEDIN_ORG_URN
```
(Alternatively, the DW Company Page's numeric id is visible in the Page admin URL; wrap it as
`urn:li:organization:<id>`.) Verify the returned org is Designer Wallcoverings before saving.

### 4d. Route secrets + deploy
Route both values via the `secrets` skill so they fan out to the master `.env`, the MCC project
`.env`, and the Kamatera MCC `.env`:
- `LINKEDIN_ACCESS_TOKEN=<60-day token>`
- `LINKEDIN_ORG_URN=urn:li:organization:<DW numeric id>`

Then on Kamatera restart with fresh env:
```sh
pm2 restart marketing-command-center --update-env
```
Confirm the channel is now connected (does NOT expose the token):
```sh
curl -u admin:DW2024! https://marketing.designerwallcoverings.com/api/channels/status
# linkedin → connected: true, accounts: ["urn:li:organization:<id>"]
```

### 4e. Prove it — re-approve a LinkedIn Engine suggestion
The MCC Engine already carries LinkedIn suggestions (trade-voice captions). Approve one so the
scheduler fires `channels.postLinkedIn` (image `initializeUpload` → PUT bytes → `POST /rest/posts`).
Then **verify the post appears on the Designer Wallcoverings LinkedIn Company Page**.
Outbound publish is Steve-gated (fire from the MCC UI or via `!`).

---

## 5. Checklist

- [ ] **(Steve)** Create app `DW Marketing – Community` at `/developers/apps/new`, LinkedIn Page =
      Designer Wallcoverings, logo, privacy URL, **check the legal agreement**.
- [ ] Complete DW Page company verification on the new app (Settings tab).
- [ ] Capture the NEW app's client id + client secret → route to `secrets`.
- [ ] Products tab → **Request access to Community Management API ONLY** (no Share on LinkedIn).
- [ ] Business-email verify: enter `info@designerwallcoverings.com` → **George reads the fresh
      6-digit code** → enter it.
- [ ] Submit; button greys = request lodged, pending LinkedIn review (1–3 days).
- [ ] **⏳ WAIT for LinkedIn's decision email** (George/canary can watch info@ for it).
- [ ] On approval: Auth tab → enable `w_organization_social` + add MCC redirect URI.
- [ ] **(Steve)** Open authorize URL, click **Allow** as DW Page admin.
- [ ] Exchange code → 60-day `LINKEDIN_ACCESS_TOKEN`.
- [ ] Find `LINKEDIN_ORG_URN` via `/rest/organizationAcls` (or Page numeric id); confirm = DW.
- [ ] Route both to `secrets` → Kamatera `.env` → `pm2 restart marketing-command-center --update-env`.
- [ ] `channels/status` shows linkedin connected.
- [ ] Approve a LinkedIn Engine suggestion → verify the post on the DW Company Page.

---

## 6. Who does what — agent (openclaw) vs Steve vs George

| Step | Driver |
|---|---|
| Fill the Create-app form fields (name, Page, logo, privacy URL) | **Agent via openclaw real-Chrome** (Steve signed in) |
| **Check the legal-agreement box + click Create app** | **STEVE** (legal acceptance — gated) |
| Company-verify the app to the DW Page | Agent can click through; the verification link goes to a DW Page admin (Steve) |
| Products tab → Request access to Community Management API only | **Agent via openclaw** |
| Enter `info@designerwallcoverings.com` for business-email verify | **Agent** |
| **Read the 6-digit verification code** | **George** (auto-reads the info@ Gmail; no Steve action) |
| Submit the access request | **Agent** |
| **Wait for LinkedIn's decision email** | passive — **George/canary** surfaces it |
| Enable `w_organization_social` + add redirect (Auth tab) | **Agent via openclaw** |
| **Click "Allow" on the OAuth consent** (as DW Page admin) | **STEVE** (the OAuth Allow — gated) |
| Exchange code → token; find ORG_URN; route to secrets; deploy | **Agent** |
| Approve an Engine LinkedIn suggestion → verify live post | **Agent stages; publish is Steve-gated** (fire from UI) |

**Net:** the only two things that strictly need Steve in real time are (1) the **legal-agreement
checkbox** on app creation and (2) the **OAuth "Allow" click**. Everything else — form-fill,
product request, email-code reading (George), token mint, ORG_URN lookup, deploy — the agent can
drive.

---

## 7. State of the MCC adapter (already built — no build work needed post-approval)

`modules/channels/index.js`:
- `postLinkedIn(content)` reads `LINKEDIN_ORG_URN` (falls back to `LINKEDIN_AUTHOR_URN`) as the
  author, does the full image flow: `POST /rest/images?action=initializeUpload` → `PUT` the image
  bytes → `POST /rest/posts` with `commentary` (text+link) + the image URN.
- Headers pinned: `LinkedIn-Version: 202401`, `X-Restli-Protocol-Version: 2.0.0`.
- `channels/status` reports `connected` iff `LINKEDIN_ACCESS_TOKEN` **and** an org/author URN are
  present — so the token + `LINKEDIN_ORG_URN` are the only two runtime values still missing.
- The Engine already generates trade-voice LinkedIn suggestions.

So once §4 lands the two env values, LinkedIn is a live 7th channel with **zero** additional code.