# Cast GHL Provider — Project Plan ## Overview `cast-ghl-provider` is a GHL (GoHighLevel) Marketplace app that acts as a custom SMS Conversation Provider, replacing Twilio/LC-Phone with Cast.ph as the SMS backend. When a GHL user sends an SMS from Conversations, Workflows, or Bulk Actions, the message is routed through Cast.ph's API instead of Twilio. **Published to the GHL Marketplace as a free integration to drive Cast.ph SMS volume.** **Reference repo:** [ampilares/selfhostsim](https://github.com/ampilares/selfhostsim) — the `ghl/` bridge service is used as an architectural reference (not a direct fork). We rewrite in Go. --- ## Architecture ``` GHL Platform (Conversations / Workflows / Bulk Actions) ↓ ProviderOutboundMessage webhook (POST + x-wh-signature) Cast GHL Bridge (Go, deployed on Vultr) ↓ HTTPS + X-API-Key api.cast.ph (Cast SMS Backend) ↓ SMPP Carrier → Recipient Inbound (future): Recipient → Carrier → Cast SIM Gateway → Cast GHL Bridge → GHL Add Inbound Message API ``` ### Message Flows **Outbound (GHL → Recipient):** 1. User sends SMS from GHL (Conversations, Workflows, Bulk Actions, Mobile App) 2. GHL sends `ProviderOutboundMessage` webhook to the bridge's delivery URL 3. Bridge verifies `x-wh-signature` using the webhook public key 4. Bridge extracts `phone`, `message`, `messageId`, `attachments` from the payload 5. Bridge normalizes the phone number from E.164 to Philippine local format 6. Bridge calls `POST https://api.cast.ph/api/sms/send` with the message 7. Bridge calls GHL `Update Message Status` API to report `delivered` or `failed` **Inbound (Recipient → GHL) — Phase 2:** 1. Recipient replies via SMS 2. Cast SIM Gateway receives the MO message 3. Gateway POSTs to the bridge's inbound webhook endpoint 4. Bridge calls GHL `Add Inbound Message` API (type: "SMS") to insert into conversation --- ## Language & Runtime **Go** - Matches the entire Cast.ph backend stack (Go, Docker, Vultr) - Single binary deployment — no `node_modules`, no runtime deps - Lower memory footprint per instance — important for a public marketplace app handling webhooks for many GHL locations - The selfhostsim `ghl/` service logic is ~300 lines of Express routes — straightforward to implement in Go with `net/http` + a MongoDB driver - Long-term maintainability aligns with team expertise --- ## GHL Conversation Provider Contract ### Provider Type: Default SMS (replaces Twilio/LC-Phone) - Do NOT check "Is this a Custom Conversation Provider" - Supports: Conversations, Workflows, Bulk Actions, Mobile App - `conversationProviderId` is NOT required for inbound messages - Standard SMS workflow modules are supported ### Required Scopes | Scope | Purpose | |-------|---------| | `conversations/message.write` | Outbound webhook events, inbound messages, status updates | | `conversations/message.readonly` | Read message data | | `conversations.write` | Create/update conversations | | `conversations.readonly` | Query conversations | | `contacts.readonly` | Look up contacts by phone | | `contacts.write` | Create contacts for inbound from unknown numbers | ### Outbound Webhook Payload (ProviderOutboundMessage) ```json { "contactId": "GKBhT6BfwY9mjzXAU3sq", "locationId": "GKAWb4yu7A4LSc0skQ6g", "messageId": "GKJxs4P5L8dWc5CFUITM", "type": "SMS", "phone": "+639171234567", "message": "The text message to send", "attachments": [], "userId": "GK56r6wdJDrkUPd0xsmx" } ``` ### GHL API Endpoints Used | API | Method | URL | |-----|--------|-----| | Update Message Status | PUT | `https://services.leadconnectorhq.com/conversations/messages/{messageId}/status` | | Add Inbound Message | POST | `https://services.leadconnectorhq.com/conversations/messages/inbound` | | Get Access Token | POST | `https://services.leadconnectorhq.com/oauth/token` | --- ## Cast.ph API Integration ### Outbound SMS Endpoint `POST https://api.cast.ph/api/sms/send` ```json { "to": "09171234567", "message": "Hello from GHL", "sender_id": "CAST" } ``` Response: ```json { "success": true, "message_id": "abc123def456", "parts": 1 } ``` ### Key API Behaviors - Phone numbers: 11-digit Philippine format (`09XXXXXXXXX`) — bridge must normalize from E.164 - Message limit: 450 characters max (3 SMS parts) - Auth: `X-API-Key: cast_<64-hex-chars>` header - Rate limit: 30 req/s, burst 50 - Errors: `{ "success": false, "error": "..." }` ### Phone Number Normalization GHL sends E.164 format. Cast API expects Philippine local format. | Direction | Input | Output | |-----------|-------|--------| | GHL → Cast | `+639171234567` | `09171234567` | | GHL → Cast | `639171234567` | `09171234567` | | Cast → GHL | `09171234567` | `+639171234567` | --- ## Project Structure ``` cast-ghl-provider/ ├── cmd/ │ └── server/ │ └── main.go # Entry point: HTTP server, config, graceful shutdown ├── internal/ │ ├── config/ │ │ └── config.go # Env var loading + validation │ ├── ghl/ │ │ ├── oauth.go # OAuth install flow, token exchange, refresh │ │ ├── webhook.go # Outbound webhook handler + signature verification │ │ ├── api.go # GHL API client (status update, inbound message) │ │ └── types.go # GHL request/response types │ ├── cast/ │ │ ├── client.go # Cast API HTTP client │ │ └── types.go # Cast request/response types │ ├── phone/ │ │ └── normalize.go # E.164 ↔ PH local format conversion │ └── store/ │ └── mongo.go # MongoDB token/session storage ├── Dockerfile ├── docker-compose.yaml ├── .env.example ├── go.mod ├── go.sum ├── CAST_API_REFERENCE.md # Cast API docs (source of truth) ├── GHL_API_REFERENCE.md # GHL conversation provider docs ├── CLAUDE.md # Claude Code project instructions ├── .claude/tasks/ # Sequential dev tasks │ ├── 01-init.md │ ├── 02-config-and-store.md │ ├── 03-cast-client.md │ ├── 04-phone-normalize.md │ ├── 05-ghl-oauth.md │ ├── 06-ghl-webhook.md │ ├── 07-ghl-api.md │ ├── 08-server-wiring.md │ ├── 09-docker.md │ └── 10-testing.md ├── .woodpecker.yml ├── .gitignore └── README.md ``` --- ## Configuration (env vars) ```env # Server PORT=3002 BASE_URL=https://ghl.cast.ph # Public URL for OAuth redirects + webhooks # GHL OAuth GHL_CLIENT_ID=xxx GHL_CLIENT_SECRET=xxx GHL_WEBHOOK_PUBLIC_KEY=xxx # For verifying x-wh-signature GHL_CONVERSATION_PROVIDER_ID=xxx # From GHL Marketplace app # Cast.ph CAST_API_KEY=cast_xxx CAST_API_URL=https://api.cast.ph # Optional override CAST_SENDER_ID=CAST # Default sender ID # MongoDB MONGO_URI=mongodb://localhost:27017/cast-ghl # Security INBOUND_API_KEY=xxx # Shared secret for Cast gateway → bridge auth ``` --- ## Deployment ### Docker Compose (production) ```yaml services: bridge: build: . ports: - "3002:3002" env_file: .env depends_on: - mongo restart: unless-stopped mongo: image: mongo:7 volumes: - mongo-data:/data/db restart: unless-stopped volumes: mongo-data: ``` ### Infrastructure - **Host:** Vultr (existing Cast infrastructure) - **Reverse proxy:** Nginx or Caddy with HTTPS - **Domain:** `ghl.cast.ph` (or similar) - **CI/CD:** Woodpecker CI at `git.sds.dev` --- ## GHL Marketplace Listing ### App Details - **Name:** Cast SMS - **Type:** Public (after development/testing as Private) - **Category:** SMS / Communication - **Pricing:** Free - **Description:** Send and receive SMS through Cast.ph's Philippine SMS gateway. Lower cost alternative to Twilio/LC-Phone for Philippine numbers. ### What the user does 1. Install "Cast SMS" from the GHL Marketplace 2. Authorize the app (OAuth flow) 3. Go to Settings → Phone Numbers → Advanced Settings → SMS Provider 4. Select "Cast SMS" as the default provider 5. Send SMS from Conversations — messages route through Cast.ph --- ## Risks & Mitigations | Risk | Impact | Mitigation | |------|--------|------------| | GHL custom SMS providers lack parity (missed call text-back, review requests, internal notifications still route to Twilio) | Some GHL features won't use Cast SMS | Document limitations; track GHL feature requests | | GHL Conversational AI doesn't work with custom SMS providers | AI auto-replies won't use Cast SMS | GHL platform limitation — no workaround | | OAuth token refresh failures | Messages stop flowing | Robust refresh with retry + alerting | | Phone number format mismatches | Messages fail or go to wrong numbers | Comprehensive normalizer with unit tests | | Cast API downtime | Outbound messages fail | Report `failed` status to GHL; health checks | | GHL Marketplace review rejection | Can't go public | Start as Private, iterate on feedback | --- ## Timeline | Phase | Task File | Duration | Dependencies | |-------|-----------|----------|-------------| | Init & config | 01, 02 | 1-2 days | GHL Marketplace app created | | Cast client + phone normalization | 03, 04 | 1-2 days | Cast API docs | | GHL OAuth flow | 05 | 2-3 days | GHL credentials | | Webhook handler + outbound | 06 | 2-3 days | OAuth working | | GHL API client (status updates) | 07 | 1-2 days | Webhook handler | | Server wiring | 08 | 1 day | All components | | Docker + deployment | 09 | 1-2 days | Vultr access | | Testing | 10 | 2-3 days | Everything | | **Total MVP** | | **~2-3 weeks** | | | Inbound SMS (Phase 2) | Future | 1 week | Cast SIM gateway webhook | | GHL Marketplace submission | Future | 1-2 weeks | Stable MVP | --- ## Reference Links | Resource | URL | |----------|-----| | GHL Conversation Providers | https://marketplace.gohighlevel.com/docs/marketplace-modules/ConversationProviders | | ProviderOutboundMessage webhook | https://marketplace.gohighlevel.com/docs/webhook/ProviderOutboundMessage | | Add Inbound Message API | https://marketplace.gohighlevel.com/docs/ghl/conversations/add-an-inbound-message | | Update Message Status API | https://marketplace.gohighlevel.com/docs/ghl/conversations/update-message-status | | GHL OAuth docs | https://marketplace.gohighlevel.com/docs/Authorization/authorization_doc | | GHL Scopes | https://marketplace.gohighlevel.com/docs/oauth/Scopes | | selfhostsim (reference) | https://github.com/ampilares/selfhostsim | | GHL Marketplace app template | https://github.com/GoHighLevel/ghl-marketplace-app-template | | Cast API docs | CAST_API_REFERENCE.md (in repo) |