cast-ghl-plugin/CLAUDE.md
Head of Product & Engineering a2826a3da7
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: switch webhook signature verification from ECDSA to RSA-PKCS1v15+SHA-256
GHL uses RSA + SHA-256 for x-wh-signature, not ECDSA P-256 as documented
in the original task files. Also adds forward-compatible Ed25519 support
for X-GHL-Signature (GHL migration scheduled July 2026): handler checks
X-GHL-Signature first, falls back to x-wh-signature.

- webhook.go: replace ecdsa.VerifyASN1 with rsa.VerifyPKCS1v15; add
  verifyEd25519 + verifyIncomingSignature dispatch; update struct fields
- webhook_test.go: regenerate test keys as RSA-2048, sign with PKCS1v15
- CLAUDE.md: correct crypto stack and key implementation notes
- .env.example: clarify GHL_WEBHOOK_PUBLIC_KEY is a static RSA key from docs

Co-Authored-By: SideKx <sidekx.ai@sds.dev>
2026-04-05 01:15:56 +02:00

7.1 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) + 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 <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 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 concernghl/, 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://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

  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).