Complete MVP implementation of the Cast GHL Conversation Provider bridge: - Go module setup with chi router and mongo-driver dependencies - Config loading with env var validation and defaults - MongoDB token store with upsert, get, update, delete operations - Cast.ph SMS client with 429 retry logic and typed errors - Phone number normalization (E.164 ↔ Philippine local format) - GHL OAuth 2.0 install/callback/refresh flow - GHL webhook handler with ECDSA signature verification (async dispatch) - GHL API client for message status updates and inbound message stubs - Multi-stage Dockerfile, docker-compose with MongoDB, Woodpecker CI pipeline - Unit tests for phone normalization, Cast client, GHL webhook, and OAuth handlers Co-Authored-By: SideKx <sidekx.ai@sds.dev>
6.7 KiB
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.22+
- HTTP:
net/http(stdlib) +chirouter (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/ecdsa+crypto/sha256(webhook signature verification) - 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 <access_token> (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:
{
"contactId": "...",
"locationId": "...",
"messageId": "...",
"type": "SMS",
"phone": "+639171234567",
"message": "text to send",
"attachments": [],
"userId": "..."
}
Verified via x-wh-signature header using ECDSA + SHA256.
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 withfmt.Errorf("context: %w", err) - Logging:
log/slog(structured logging, stdlib). JSON format in production. - HTTP handlers:
func(w http.ResponseWriter, r *http.Request)— standardnet/http - Context: Pass
context.Contextthrough all external calls (HTTP, MongoDB) - Tests:
_test.gofiles alongside source. Usehttptestfor 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://ghl.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
- Work through
.claude/tasks/in order (01 → 10) go build ./cmd/server/after each taskgo test ./...for testsgo vet ./...for static analysis- Docker:
docker compose up --buildfor full stack
Key Implementation Notes
- Webhook signature verification is mandatory — GHL sends
x-wh-signatureon every webhook. Verify with ECDSA P-256 + SHA-256 using the public key from env. - OAuth tokens are per-location — store
locationId→{ access_token, refresh_token, expires_at }in MongoDB. Refresh before expiry. - Phone normalization is critical — GHL sends E.164 (
+639XXXXXXXXX), Cast expects09XXXXXXXXX. Get this wrong = messages fail. - Status updates must use the provider's token — only the conversation provider marketplace app tokens can update message status.
- Respond 200 to webhook immediately — process the SMS send asynchronously (goroutine) so GHL doesn't timeout waiting.
- Cast API has no inbound webhook yet — inbound SMS is Phase 2, after Cast SIM gateway adds webhook support.
- GHL API base is
services.leadconnectorhq.com— notrest.gohighlevel.com(that's v1, deprecated).