<!--
GoHighLevel (GHL) Integration Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# GoHighLevel (GHL) Integration Playbook

**Portable spec for integrating GoHighLevel into a new app — OAuth + PIT JWT, location selection, social/blog publishing, CRM sync, webhooks.**

Built from the BKE / Kompozy implementation. Reference repo: `reference app`.

GHL is the dominant infrastructure layer for the BILT CRM customer base and most BILT portfolio marketing apps. Integrating cleanly with GHL is a recurring task — this playbook locks in the patterns and the landmines we've already paid for.

---

## 0. Use this spec

**Inputs:**
- GHL Marketplace app credentials: `GHL_CLIENT_ID`, `GHL_CLIENT_SECRET`, `GHL_REDIRECT_URI` (only if doing OAuth)
- Scopes required by the app: typically `locations.readonly contacts.write social-media-posting.write blogs/posts.write` — narrow this per app
- Auth strategy: OAuth (end-user installs your app) vs PIT (owner pastes a token) vs both
- Whether the app publishes to social/blog, syncs CRM, or just reads data

**Outputs:**
- `/api/auth/ghl/install` + `/api/auth/ghl/callback` routes (OAuth path)
- `/onboarding/ghl-connect` or settings panel for PIT paste (PIT path)
- `platform_connections` row per workspace, refresh-token rotation, location picker UI
- Publishing wrappers (`publish-ghl.ts`, `publish-ghl-blog.ts`) with the wire-platform normalizer
- Optional: webhook receiver at `/api/webhooks/ghl` for inbound CRM events

---

## 1. Architecture

```
src/
├── lib/
│   ├── ghl/
│   │   ├── client.ts                 # fetch wrapper with auth header + retry
│   │   ├── oauth.ts                  # token exchange + refresh rotation
│   │   ├── pit.ts                    # PIT JWT validation + storage
│   │   ├── locations.ts              # list / select GHL sub-accounts
│   │   ├── social-accounts.ts        # fetch FB/IG/etc account list per location
│   │   └── wire-platform.ts          # canonical platform-name normalizer
│   │
│   ├── publish/
│   │   ├── publish-ghl.ts            # social post fanout
│   │   └── blog/
│   │       ├── publish-ghl-blog.ts   # blog post creation
│   │       └── ghl-media.ts          # media MIME normalization
│   │
│   └── crm/
│       └── ghl-sync.ts               # optional contact sync helpers
│
├── app/
│   ├── api/
│   │   ├── auth/ghl/install/route.ts
│   │   ├── auth/ghl/callback/route.ts
│   │   ├── publish-ghl/route.ts
│   │   └── webhooks/ghl/route.ts     # add to PUBLIC_API_PREFIXES
│   │
│   └── dashboard/connections/
│       └── ghl/page.tsx              # connect, pick location, see status
```

---

## 2. Schema

```sql
create table platform_connections (
  id uuid primary key default gen_random_uuid(),
  workspace_id uuid not null references workspaces(id) on delete cascade,
  provider text not null check (provider in ('ghl','fb','ig','x','linkedin','tiktok','yt')),
  auth_type text not null check (auth_type in ('oauth','pit','api_key')),
  access_token text,                  -- encrypted at rest if possible
  refresh_token text,                 -- OAuth only
  expires_at timestamptz,             -- OAuth only; PIT is long-lived
  scopes text[],
  board_ids jsonb default '{}',       -- location ids, user ids, page ids
  status text default 'active' check (status in ('active','expired','revoked','error')),
  last_synced_at timestamptz,
  last_error text,
  created_at timestamptz default now(),
  updated_at timestamptz default now(),
  unique (workspace_id, provider)
);

alter table platform_connections enable row level security;
create policy "workspace members read" on platform_connections for select using (
  exists (select 1 from workspaces w where w.id = workspace_id and w.owner_id = auth.uid())
);
```

**`board_ids` jsonb shape (load-bearing — read carefully):**

```jsonc
{
  "ghl_location_id": "abc123",        // active sub-account
  "ghl_user_id": "user_xyz",          // REQUIRED for create-post (PIT JWTs lack it)
  "ghl_company_id": "comp_456",
  "fb_page_id": "1234567890",         // MUST be merged, not overwritten (see §7)
  "fb_page_name": "Acme Realty",
  "ig_account_id": "17841...",
  "linkedin_urn": "urn:li:organization:...",
  "tiktok_account_id": "...",
  "yt_channel_id": "UC..."
}
```

---

## 3. OAuth 2.0 flow (end-user installs your app)

Use when end-users in the BILT/Kompozy customer base click "Connect GoHighLevel" inside your app.

**Endpoints (verify against current GHL docs before shipping — they version these):**
- Authorize: `https://marketplace.gohighlevel.com/oauth/chooselocation`
- Token exchange: `https://services.leadconnectorhq.com/oauth/token`
- API base: `https://services.leadconnectorhq.com`
- Required header: `Version: 2021-07-28` (or the latest pinned version)

**Flow:**
1. `/api/auth/ghl/install` → 302 to authorize URL with `client_id`, `redirect_uri`, `scope`, `state` (HMAC of workspace_id + nonce).
2. GHL returns to `/api/auth/ghl/callback?code=...&state=...`. Verify state HMAC.
3. POST to token endpoint with `grant_type=authorization_code`, `code`, `client_id`, `client_secret`, `user_type=Location` (or `Company`).
4. Response: `{ access_token, refresh_token, expires_in, locationId, userId, companyId, scope }`.
5. Upsert into `platform_connections` with `auth_type='oauth'`. Populate `board_ids.ghl_location_id`, `board_ids.ghl_user_id`, `board_ids.ghl_company_id` from the response — these are the values PIT JWTs CAN'T give you, so capture them now.
6. Redirect to `/dashboard/connections/ghl` showing location picker if `user_type=Company` returned multiple locations.

**Refresh rotation:** wrap every GHL call in `withFreshToken(connection)` that checks `expires_at < now() + 60s` and refreshes proactively. GHL refresh tokens rotate — store the new refresh_token on every refresh response, NOT just access_token.

---

## 4. Private Integration Token (PIT JWT) flow

Use when the workspace owner already has GHL admin access and prefers a paste-token UX (faster, no marketplace app review). Common for BILT CRM customers who don't want to install yet another marketplace app.

**Flow:**
1. Show docs in-app: "GHL Settings → Private Integrations → Create new token → tick required scopes → paste below."
2. User pastes PIT into `/dashboard/connections/ghl` form.
3. Backend: decode JWT to extract `locationId` (PIT JWTs encode location), but **NOT** userId (see gotcha §6).
4. Validate by calling `GET /locations/{locationId}` with the token.
5. Upsert into `platform_connections` with `auth_type='pit'`, `expires_at=null`, no `refresh_token`.
6. **Prompt user separately for `ghl_user_id`** — open a help modal: "Find this in GHL → Settings → My Staff → click your name → URL contains `/staff/{userId}`." Store in `board_ids.ghl_user_id`.

**Why PIT over OAuth for BILT-adjacent apps:** no marketplace approval, no client secret rotation, no refresh dance. The tradeoff is the manual userId step. Worth it.

---

## 5. API Key (legacy v1) — DO NOT USE

GHL's v1 API key is deprecated. It works for a narrowing surface of endpoints, can't access v2 social/blog publishing, and GHL has signaled removal. New integrations must be OAuth or PIT. Document this and refuse to wire it.

---

## 6. CRITICAL GOTCHA — `userId` requirement on create-post

**Source:** BKE memory `bke_ghl_publish.md`.

The GHL social media create-post API requires a `userId` field in the request body. **PIT JWTs do not encode `userId`** — they encode `locationId` and `companyId` only. The OAuth callback DOES return `userId`, so OAuth installs are fine. PIT installs must collect userId separately and persist it at `platform_connections.board_ids.ghl_user_id`.

Every publish helper must read it like this:

```ts
const userId = connection.board_ids?.ghl_user_id;
if (!userId) throw new Error('GHL userId missing — reconnect and provide it');
```

Do this validation at publish-time AND at connection-save time. Failing fast on save is the better UX.

---

## 7. CRITICAL GOTCHA — destructive sync of `board_ids`

**Source:** BKE lab note 2026-04-28.

GHL's `GET /social-media-posting/{locationId}/accounts` endpoint returns the connected FB/IG/etc accounts BUT **does not return `fb_page_id`** in a form usable for publishing. The pageId must be entered manually by the user (or scraped via a separate endpoint).

If you rebuild `board_ids` from the API response on every re-sync, you **wipe the user-entered pageId** and publishing 400s on next attempt.

**Rule:** sync is **upsert-only, truthy-only-overwrite**. Merge incoming fields into the existing `board_ids`; only overwrite a field when the new value is truthy and non-empty.

```ts
const merged = { ...connection.board_ids };
for (const [k, v] of Object.entries(incoming)) {
  if (v !== null && v !== undefined && v !== '') merged[k] = v;
}
```

Never `board_ids = incoming`. Never `DELETE from connections WHERE provider='ghl' AND ...`.

---

## 8. CRITICAL GOTCHA — media MIME types

**Source:** BKE memory `feedback_ghl_media_no_type.md`.

When publishing posts with media, GHL's `media` array requires each entry to be `{ url: string, type: string }` where `type` is a **MIME type** (`'image/png'`, `'image/jpeg'`, `'video/mp4'`).

- Sending `type: 'image'` (category alias) → **422 Unprocessable**.
- Omitting `type` entirely → **400 with internal `.includes` crash on GHL's side**.

Normalize before sending — reference: `src/lib/publish/blog/ghl-media.ts`.

```ts
function toGhlMedia(url: string): { url: string; type: string } {
  const ext = url.split('?')[0].split('.').pop()?.toLowerCase() ?? '';
  const mime = ({
    png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
    webp: 'image/webp', gif: 'image/gif', mp4: 'video/mp4', mov: 'video/quicktime',
  } as Record<string, string>)[ext];
  if (!mime) throw new Error(`Unknown media extension: ${ext} (${url})`);
  return { url, type: mime };
}
```

---

## 9. CRITICAL GOTCHA — `instagramPostDetails` not `instagramPost`

GHL's social create-post body uses platform-specific detail objects: `facebookPostDetails`, `instagramPostDetails`, `tiktokPostDetails`, `youtubePostDetails`, `linkedinPostDetails`, `xPostDetails`.

Docs are inconsistent — some pages show `instagramPost` (no `Details` suffix). The working field name is **`instagramPostDetails`**. Same pattern for the others. If you see a 200 response but the post never appears in IG, this is why.

---

## 10. Social publish — wire-platform normalizer

Reference: `src/lib/publish/wire-platform.ts`.

GHL expects platform names in their casing inside the `type` array of the request body: `['Facebook', 'Instagram', 'TikTok', 'LinkedIn', 'YouTube', 'Twitter']` (title case, except `LinkedIn` and `YouTube` which are camel-pascal).

Internally the app probably uses lowercase (`'facebook'`, `'instagram'`, ...). Centralize the conversion in `wire-platform.ts`:

```ts
const CANONICAL: Record<string, string> = {
  facebook: 'Facebook', fb: 'Facebook',
  instagram: 'Instagram', ig: 'Instagram',
  tiktok: 'TikTok', tt: 'TikTok',
  linkedin: 'LinkedIn', li: 'LinkedIn',
  youtube: 'YouTube', yt: 'YouTube',
  twitter: 'Twitter', x: 'Twitter',
};
export function wireGhlPlatform(p: string): string {
  const c = CANONICAL[p.toLowerCase()];
  if (!c) throw new Error(`Unknown platform: ${p}`);
  return c;
}
```

Every call site that builds a GHL payload runs platform names through this. No ad-hoc casing.

Cross-reference: PUBLISHING-PIPELINE-PLAYBOOK covers the broader pre-publish pipeline. This playbook only covers GHL specifics.

---

## 11. Blog publish

Reference: `src/lib/publish/blog/publish-ghl-blog.ts`.

Endpoint: `POST /blogs/posts` (verify current path against docs).

Required fields:
- `locationId` — from `board_ids.ghl_location_id`
- `blogId` — fetched via `GET /blogs?locationId=...` and shown in a picker
- `title`, `rawHtml` (or `description`), `imageUrl` (use the same MIME-typed media object)
- `categories: string[]` — fetched via `GET /blogs/categories?locationId=...&blogId=...`
- `tags: string[]`
- `author` — userId from `board_ids.ghl_user_id`
- `urlSlug`
- `status: 'PUBLISHED' | 'DRAFT' | 'SCHEDULED'`
- `publishedAt` (ISO) — required when `status='SCHEDULED'`

Scheduled posts: pass a UTC ISO timestamp. GHL handles the cron — your app does not need to poll/re-fire. Mark the local row as `status='scheduled'` and rely on GHL to publish.

---

## 12. CRM sync (optional)

If the app needs contacts, opportunities, or pipeline data:

- **Pull:** `GET /contacts?locationId=...&limit=100` with cursor pagination. Upsert into your `contacts` table by `ghl_contact_id`.
- **Push events back:** `POST /contacts/{contactId}/notes` or `POST /contacts/{contactId}/tasks` to log activity from your app into the GHL contact timeline.
- **Custom fields:** `GET /locations/{locationId}/customFields` to list, then reference by ID in contact upserts. Map your app's fields → GHL custom field IDs in a small lookup table per workspace.
- **Sync cadence:** webhook-first (see §13). Fall back to incremental polling every 15 min using `updatedAt > last_synced_at`. Never full-rebuild.

---

## 13. Webhook receiver

GHL can send webhooks for ContactCreate, ContactUpdate, OpportunityStageChange, AppointmentCreate, etc.

**Setup:**
1. Register webhook in marketplace app config (OAuth) or via API (PIT path: `POST /hooks/webhooks`).
2. Route: `/api/webhooks/ghl/route.ts`.
3. **Add to `PUBLIC_API_PREFIXES`** in `proxy.ts` — webhooks come from GHL servers without a Supabase session and will 401 otherwise (cross-ref: BKE memory `bke_proxy_default_deny.md`).
4. Verify signature: GHL signs with HMAC-SHA256 using your app's secret. Reject if mismatch.
5. Idempotency: GHL retries on non-2xx. Dedupe by `eventId` in a `webhook_events` table; return 200 on duplicate.
6. Enqueue heavy work to Trigger.dev or a queue — return 200 fast (GHL times out at 10s).

---

## 14. Common pitfalls

**❌ Don't store API keys/PIT tokens in env vars.** Per-workspace storage in `platform_connections`. Env vars are for the marketplace app's `CLIENT_ID`/`CLIENT_SECRET` only.

**❌ Don't trust GHL's account list to be authoritative.** Merge with user-entered fields (`fb_page_id`, custom labels). Truthy-only-overwrite.

**❌ Don't omit `media[].type`.** 400 with `.includes` crash on GHL's side. Always normalize via MIME mapper.

**❌ Don't case-mismatch platform names.** Always normalize via `wire-platform.ts`. GHL expects title-case-ish (`Facebook`, `Instagram`, `LinkedIn`, `YouTube`, `Twitter`, `TikTok`).

**❌ Don't rebuild `board_ids` on every re-sync.** Preserves manually-entered FB pageId. This is the destructive-sync class of bug.

**❌ Don't skip refresh_token rotation.** OAuth tokens expire in ~24h; refresh proactively at `expires_at - 60s`. Store the new refresh_token returned in the refresh response.

**❌ Don't publish to all locations by default.** Force the user to pick an active location per workspace. Multi-location accounts will accidentally blast to every sub-account otherwise.

**❌ Don't forget `userId` in create-post payload.** PIT JWTs lack it — pull from `board_ids.ghl_user_id`. Fail fast at save-time, not publish-time.

**❌ Don't forget `Version` header.** GHL requires `Version: 2021-07-28` (or current pinned). Missing it returns 400.

**❌ Don't poll when a webhook would work.** Webhooks are free and instant; polling burns rate limit and lags.

**❌ Don't ship without an explicit reconnect UX.** Tokens get revoked when users uninstall in GHL UI — surface `status='revoked'` clearly with a one-click reconnect, not a silent failure.

---

## 15. Kickoff prompt

> Execute the GHL Integration Playbook at `<workspace>/templates/b-tier/GHL-INTEGRATION-PLAYBOOK.md` for this app. Mirror the BKE implementation at `<reference-app>/src/lib/publish/` and `src/app/api/publish-ghl/`. When in doubt about a payload shape, read the BKE file rather than re-deriving from docs.
>
> Inputs:
> - Auth strategy: [OAuth / PIT / both]
> - Marketplace app credentials: [provided in .env]
> - Required scopes: [list]
> - Surfaces: [social publish? blog publish? CRM sync? webhooks?]
>
> Pause after `platform_connections` schema + OAuth callback (or PIT save) ships for review.

---

**Reference files in BKE:**
- `src/lib/publish/wire-platform.ts` — canonical platform-name normalizer
- `src/lib/publish/blog/publish-ghl-blog.ts` — blog post creation
- `src/lib/publish/blog/ghl-media.ts` — MIME normalization
- `src/app/api/publish-ghl/route.ts` — social publish API
- `src/proxy.ts` — for `PUBLIC_API_PREFIXES` webhook allowlist

**Cross-reference playbooks:**
- AUTH-ONBOARDING-PLAYBOOK (s-tier) — Supabase auth + `platform_connections` RLS pattern
- PUBLISHING-PIPELINE-PLAYBOOK — broader pre-publish normalization, retries, scheduled-publish worker
