# cast-ghl-provider — Claude Code Project Instructions ## What This Is `cast-ghl-provider` is a Go HTTP service that acts as a GHL (GoHighLevel) Marketplace SMS Conversation Provider. It bridges GHL's outbound SMS webhooks to Cast.ph's SMS API, replacing Twilio/LC-Phone as the default SMS provider for GHL sub-accounts. ## Stack - **Language:** Go 1.26+ - **HTTP:** `net/http` (stdlib) + `chi` router (lightweight) - **Database:** MongoDB (OAuth token storage) - **Mongo driver:** `go.mongodb.org/mongo-driver/v2` - **HTTP client:** `net/http` (stdlib, no external HTTP client) - **JSON:** `encoding/json` (stdlib) - **Crypto:** `crypto/rsa` + `crypto/sha256` (webhook sig: RSA-PKCS1v15+SHA-256); `crypto/ed25519` (X-GHL-Signature fallback, active July 2026+) - **Config:** Environment variables only (no config files) - **Deploy:** Docker + Docker Compose on Vultr ## External APIs ### Cast.ph SMS API **Base URL:** `https://api.cast.ph` **Auth:** `X-API-Key: cast_<64-hex-chars>` **Full docs:** `CAST_API_REFERENCE.md` in repo root | Endpoint | Method | Purpose | |----------|--------|---------| | `/api/sms/send` | POST | Send outbound SMS | Request: `{ "to": "09171234567", "message": "text", "sender_id": "CAST" }` Response: `{ "success": true, "message_id": "abc123", "parts": 1 }` Key behaviors: - Phone numbers: 11-digit Philippine format (`09XXXXXXXXX`) - Message max: 450 characters (3 SMS parts) - Rate limit: 30 req/s, burst 50, 429 with Retry-After - Errors: `{ "success": false, "error": "..." }` ### GHL API **Base URL:** `https://services.leadconnectorhq.com` **Auth:** `Authorization: Bearer ` (OAuth 2.0) **Provider docs:** `GHL_API_REFERENCE.md` in repo root | Endpoint | Method | Purpose | |----------|--------|---------| | `/oauth/token` | POST | Exchange auth code / refresh token | | `/conversations/messages/{messageId}/status` | PUT | Update outbound message status | | `/conversations/messages/inbound` | POST | Post inbound SMS to GHL (Phase 2) | ### GHL Webhook (inbound TO our service) GHL sends `ProviderOutboundMessage` to our delivery URL: ```json { "contactId": "...", "locationId": "...", "messageId": "...", "type": "SMS", "phone": "+639171234567", "message": "text to send", "attachments": [], "userId": "..." } ``` Verified via `x-wh-signature` header using RSA-PKCS1v15 + SHA-256. Future: `X-GHL-Signature` header using Ed25519 (GHL migration July 2026). ## Project Structure ``` cast-ghl-provider/ ├── cmd/ │ └── server/ │ └── main.go # Entry: config, routes, graceful shutdown ├── internal/ │ ├── config/ │ │ └── config.go # Env var loading + validation │ ├── ghl/ │ │ ├── oauth.go # OAuth install, callback, token refresh │ │ ├── webhook.go # Outbound webhook handler + sig verify │ │ ├── api.go # GHL API client (status update, inbound) │ │ └── types.go # GHL types │ ├── cast/ │ │ ├── client.go # Cast API HTTP client │ │ └── types.go # Cast types │ ├── phone/ │ │ └── normalize.go # E.164 ↔ PH local conversion │ └── store/ │ └── mongo.go # MongoDB token/session storage ├── Dockerfile ├── docker-compose.yaml ├── .env.example ├── go.mod ├── CAST_API_REFERENCE.md ├── GHL_API_REFERENCE.md ├── CLAUDE.md # THIS FILE ├── .claude/tasks/ # Sequential dev tasks └── README.md ``` ## Conventions - **One package per concern** — `ghl/`, `cast/`, `phone/`, `store/`, `config/` - **No global state** — pass dependencies via struct fields (dependency injection without a framework) - **Error handling:** Return `error`, never panic. Wrap with `fmt.Errorf("context: %w", err)` - **Logging:** `log/slog` (structured logging, stdlib). JSON format in production. - **HTTP handlers:** `func(w http.ResponseWriter, r *http.Request)` — standard `net/http` - **Context:** Pass `context.Context` through all external calls (HTTP, MongoDB) - **Tests:** `_test.go` files alongside source. Use `httptest` for HTTP mocks. - **Naming:** Follow Go conventions — exported types are PascalCase, packages are lowercase single-word ## Config (env vars) | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `PORT` | No | `3002` | Server listen port | | `BASE_URL` | Yes | — | Public URL (e.g. `https://hl.cast.ph`) | | `GHL_CLIENT_ID` | Yes | — | GHL Marketplace app client ID | | `GHL_CLIENT_SECRET` | Yes | — | GHL Marketplace app client secret | | `GHL_WEBHOOK_PUBLIC_KEY` | Yes | — | PEM-encoded ECDSA public key for webhook sig | | `GHL_CONVERSATION_PROVIDER_ID` | Yes | — | Conversation provider ID from GHL app | | `CAST_API_KEY` | Yes | — | Cast.ph API key | | `CAST_API_URL` | No | `https://api.cast.ph` | Cast API base URL | | `CAST_SENDER_ID` | No | — | Default sender ID (uses account default if empty) | | `MONGO_URI` | Yes | — | MongoDB connection string | | `INBOUND_API_KEY` | No | — | Shared secret for future inbound webhook auth | Validated at startup. Missing required vars → log error + os.Exit(1). ## Routes | Method | Path | Handler | Purpose | |--------|------|---------|---------| | `GET` | `/health` | healthCheck | Health check | | `GET` | `/install` | ghl.HandleInstall | Start OAuth flow | | `GET` | `/oauth-callback` | ghl.HandleCallback | OAuth redirect handler | | `POST` | `/api/ghl/v1/webhook/messages` | ghl.HandleWebhook | Outbound SMS webhook | | `POST` | `/api/ghl/v1/inbound-sms` | ghl.HandleInbound | Inbound SMS (Phase 2) | ## Development Workflow 1. Work through `.claude/tasks/` in order (01 → 10) 2. `go build ./cmd/server/` after each task 3. `go test ./...` for tests 4. `go vet ./...` for static analysis 5. Docker: `docker compose up --build` for full stack ## Key Implementation Notes 1. **Webhook signature verification is mandatory** — GHL sends `x-wh-signature` on every webhook. Verify with RSA-PKCS1v15 + SHA-256 using the static RSA public key from GHL docs (set in `GHL_WEBHOOK_PUBLIC_KEY` env var). From July 2026, also support `X-GHL-Signature` (Ed25519). The handler checks `X-GHL-Signature` first and falls back to `x-wh-signature`. 2. **OAuth tokens are per-location** — store `locationId` → `{ access_token, refresh_token, expires_at }` in MongoDB. Refresh before expiry. 3. **Phone normalization is critical** — GHL sends E.164 (`+639XXXXXXXXX`), Cast expects `09XXXXXXXXX`. Get this wrong = messages fail. 4. **Status updates must use the provider's token** — only the conversation provider marketplace app tokens can update message status. 5. **Respond 200 to webhook immediately** — process the SMS send asynchronously (goroutine) so GHL doesn't timeout waiting. 6. **Cast API has no inbound webhook yet** — inbound SMS is Phase 2, after Cast SIM gateway adds webhook support. 7. **GHL API base is `services.leadconnectorhq.com`** — not `rest.gohighlevel.com` (that's v1, deprecated).