# GHL OAuth tokens — lifecycle, token types, endpoint test results

**Current app: "Hermes App V3"** (created 2026-08-03, Client ID `6a70395a17e1fb08c0025342-mscw8lp0`) — pricing=free, target=Sub-account, install=Agency only. Replaced V2 (freemium-locked, uninstalled). LocationIds: RRR `4yTvHUHrmVuP8CgCt1Q9`, Test Sandbox `VLPUVcvUDOaXhgFPKiGl`, Pipeline Layer `k6PTFz0gXAdNJLDT1aji`.

## Token types — CORRECTED 2026-08-03

- **Agency-level** (`userType: Company`, `locationId: null`): broad scope set (168 scopes on V3). **CAN read AND write sub-account data on MANY endpoints — the 7/30 "agency can't write to sub-accounts" rule was WRONG (scope gap on the old app, not a platform rule).** Verified working with V3 agency token: Social Planner accounts list + post create on RRR (201), Social Planner accounts on Pipeline Layer, `POST /locations/` (SaaS-mode sub-account creation). Still blocked: funnels API (`GET /funnels/funnel/list` → 401 authClass). **Rule of thumb: try the agency token first; only fall back to location-level if a specific endpoint 401s with authClass.**
- **Location-level** (`userType: Location`, `locationId: <id>`): full read/write inside that one sub-account only. Cross-location use 401s `This location is not accessible from this token!`

## Handler: multi-token since 2026-08-03 (v2.1)

`ghl-oauth.json` holds a `tokens` dict keyed by locationId (agency tokens under `agency:<companyId>`) plus an `active` pointer; top level mirrors the most recently saved token for legacy readers (`json.load(...)['access_token']` still works). Installs never clobber each other. Refresh loop walks ALL stored tokens.

Endpoints (all on `http://127.0.0.1:9120`, external via `https://robblake.cloud/ghl/...`):
- `GET /ghl/health` — status + every token's locationId/userType/expiry
- `GET /ghl/token?locationId=<id>` — raw token payload for that location (omit param = active)
- `GET /ghl/oauth/callback` — GHL redirect target
- `POST /ghl/webhook` — webhook receiver (v2.1); logs every event to `/root/.hermes/ghl-oauth/webhooks.jsonl` (rotates at 5MB), returns 200 immediately. No signature verification yet — add Ed25519 `x-ghl-signature` verification before building reactive flows on it.

Historical note: pre-2026-08-03 the file held ONE token (last install won) — that's how RRR's original 7/29 7:00 PM token was silently destroyed by the 7:51 PM sandbox install. GHL's "Active on N sub-accounts" UI count is cosmetic install state, NOT proof of a token in hand — the journalctl "tokens saved" line is the only truth.

## THE install flow that works (locked 2026-08-03 after ~6 failed attempts)

**Do NOT fight for per-location direct-link installs. The working model:**

1. App config: pricing=**free** (freemium hard-blocks direct chooselocation links with "Paid apps can only be installed within the platform" — unconditional as of 2026-08-03; the 7/30 uninstall-first workaround no longer holds). Target=Sub-account, Who-can-install=Agency only, Listing type=Standard.
2. **Direct chooselocation link, NO `scope=` param** (GHL falls back to the app's registered scopes; the short link also survives chat/terminal paste — the 700-char full-scope link got mangled):
   `https://marketplace.gohighlevel.com/oauth/chooselocation?response_type=code&redirect_uri=https://robblake.cloud/ghl/oauth/callback&client_id=<CLIENT_ID>`
3. At the account picker, choose the **AGENCY** entry (not a sub-account). Next screen: **"Install under all locations in <Agency>"** → Proceed → tick **"Enable automatic installation to future locations"** → Continue.
4. Result: ONE agency-level token (userType=Company) with the app's full scope set, working across all current AND future sub-accounts. This is the "total control" model Rob wanted — one token, all locations, new sub-accounts auto-covered.
5. **"Scopes can't be empty" error** during install = the app has no scopes registered in the marketplace dashboard. Scopes lock at app-save time; adding scopes later requires editing the app AND reinstalling to mint a token with the new set. The ✅ page echoing `agency:<companyId>` vs `<locationId>` tells you which grant you got.
6. **Scopeless short link works for agency-level installs only (2026-08-14).** For **location-level** installs (single sub-account ticked), the short scopeless link returns `Error: Scopes can't be empty` at the authorize screen. Use the **full link with `scope=` spelled out** for location installs — it's ~700 chars, must paste as one unbroken string into the address bar. Example:
   ```
   scope=voice-ai-agents.readonly+voice-ai-agents.write+contacts.readonly+contacts.write+conversations.readonly+conversations.write+calendars.readonly+calendars.write+locations.readonly+phonenumbers.read+users.readonly
   ```
7. GHL's UI Install Button on the marketplace listing = agency-level install, always. The per-sub-account picker inside UI flows still mints agency tokens. Don't bother.

**Sub-account creation via API:** `POST /locations/` with `{name, companyId, email}` + agency token → 201 (used for Test Sandbox 7/30, Pipeline Layer 8/3). With auto-install enabled, the new sub-account is immediately accessible via the agency token (verified: Pipeline Layer Social Planner readable seconds after creation).

**Known limit:** website/funnel CLONING between sub-accounts is UI-only (agency → sub-accounts → snapshots or the clone menu). No API path exists — don't promise it.

## Social Planner API (verified live 2026-08-03, agency token)

Base: `/social-media-posting/...` — scopes `socialplanner/*` all present on V3.

| Endpoint | Notes |
|---|---|
| `GET /social-media-posting/{loc}/accounts` | Lists connected accounts incl. platform, type, expiry, `id` needed for posting |
| `POST /social-media-posting/{loc}/posts` | Create/schedule. **Working payload:** `{"accountIds":[...], "summary":"...", "type":"post", "status":"scheduled", "scheduleDate":"2026-08-03T09:00:00-06:00", "media":[{"url":"https://...","type":"image/png"}], "userId":"<oauthId of the account>"}` → 201 |

Payload gotchas (each one cost a 422):
- `media[].type` must be a **MIME type** (`image/png`, `image/jpeg`) — bare `"image"` 422s with `Invalid media format type`
- No `platform` field — 422 `property platform should not exist`
- `scheduleDate` (NOT `scheduledAt`) — ISO8601 with offset
- `type` must be `post` (not `text`); valid: post/story/reel
- `userId` required — use the account's `oauthId` from the accounts list
- Create response echoes `"platform":"google"` — a display artifact, NOT the destination. Verify in GHL UI → Social Planner → Scheduled tab if unsure; there is no simple list-posts GET (404s; a POST-based `/posts/list` exists but has its own param quirks)
- Connecting a social account itself is a browser OAuth click-through (Rob's login) — not API-scriptable
- LinkedIn profile token expires ~60 days out (`expire` field on the account) — connection will need periodic re-auth via UI
