cast-ghl-plugin/.claude/tasks/06-ghl-webhook.md
Head of Product & Engineering a40a4aa626
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: initial implementation of Cast GHL Provider
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>
2026-04-04 17:27:05 +02:00

134 lines
4.8 KiB
Markdown

# Task 06: GHL Webhook Handler (Outbound SMS)
## Objective
Build `internal/ghl/webhook.go` — receives outbound SMS webhooks from GHL, verifies the signature, and dispatches the SMS via Cast.ph.
## Reference
- ProviderOutboundMessage schema: https://marketplace.gohighlevel.com/docs/webhook/ProviderOutboundMessage
- selfhostsim webhook verification logic (architectural reference)
## Types (add to `internal/ghl/types.go`)
```go
type OutboundMessageWebhook struct {
ContactID string `json:"contactId"`
LocationID string `json:"locationId"`
MessageID string `json:"messageId"`
Type string `json:"type"` // "SMS" or "Email"
Phone string `json:"phone"`
Message string `json:"message"`
Attachments []string `json:"attachments"`
UserID string `json:"userId"`
}
```
## WebhookHandler struct
```go
type WebhookHandler struct {
webhookPubKey *ecdsa.PublicKey // parsed from PEM at startup
castClient *cast.Client
ghlAPI *APIClient // for status updates
oauthHandler *OAuthHandler // for getting valid tokens
}
func NewWebhookHandler(pubKeyPEM string, castClient *cast.Client, ghlAPI *APIClient, oauth *OAuthHandler) (*WebhookHandler, error)
```
- Parse the PEM-encoded ECDSA public key at construction time
- Return error if PEM parsing fails
## Handler
### HandleWebhook — `POST /api/ghl/v1/webhook/messages`
```go
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request)
```
**Step 1: Verify signature**
1. Read `x-wh-signature` header
2. Read request body
3. Compute SHA-256 hash of the body
4. Decode the base64 signature from the header
5. Verify ECDSA signature using the public key
6. If invalid: return 401 "invalid webhook signature"
**Step 2: Parse payload**
1. Unmarshal body into `OutboundMessageWebhook`
2. If `type` is not "SMS": return 200 (ignore non-SMS webhooks silently)
**Step 3: Respond immediately**
1. Return 200 OK to GHL (don't make GHL wait for the SMS send)
**Step 4: Process async (goroutine)**
1. Normalize phone number: `phone.ToLocal(webhook.Phone)`
2. Call `castClient.SendSMS(ctx, localPhone, webhook.Message)`
3. Get valid OAuth token: `oauthHandler.GetValidToken(ctx, webhook.LocationID)`
4. On Cast success: call `ghlAPI.UpdateMessageStatus(ctx, token, webhook.MessageID, "delivered")`
5. On Cast failure: call `ghlAPI.UpdateMessageStatus(ctx, token, webhook.MessageID, "failed")`
6. Log result with `slog.Info` or `slog.Error`
## Signature Verification Detail
GHL uses ECDSA P-256 with SHA-256. The signature is in the `x-wh-signature` header as a base64-encoded ASN.1 DER-encoded ECDSA signature.
```go
func (h *WebhookHandler) verifySignature(body []byte, signatureB64 string) bool {
// 1. Decode base64 signature
sigBytes, err := base64.StdEncoding.DecodeString(signatureB64)
if err != nil {
return false
}
// 2. Hash the body
hash := sha256.Sum256(body)
// 3. Verify ECDSA signature
return ecdsa.VerifyASN1(h.webhookPubKey, hash[:], sigBytes)
}
```
### Parsing the PEM public key
```go
func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
ecdsaPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("key is not ECDSA")
}
return ecdsaPub, nil
}
```
## Key behaviors
- **Respond 200 immediately** — GHL has a timeout. Process SMS sending in a goroutine.
- **Log everything** — webhook received, signature result, Cast send result, GHL status update result
- **Non-SMS webhooks silently ignored** — return 200, log at debug level
- **Phone normalization failure** — log error, update GHL status to "failed"
- **Cast API failure** — log error, update GHL status to "failed"
- **GHL status update failure** — log error (don't retry — the message was still sent/failed)
- **Use background context for goroutine** — `context.Background()` with 30s timeout (the request context is already done)
## Acceptance Criteria
- [ ] `go build ./cmd/server/` succeeds
- [ ] PEM public key parsed at construction (not per-request)
- [ ] Signature verification uses ECDSA P-256 + SHA-256
- [ ] Invalid/missing signature returns 401
- [ ] Valid signature + SMS type: returns 200 immediately
- [ ] SMS processing happens in goroutine (async)
- [ ] Phone number normalized from E.164 to local PH format
- [ ] Cast `SendSMS` called with normalized number
- [ ] GHL status updated to "delivered" on success
- [ ] GHL status updated to "failed" on Cast error
- [ ] Non-SMS webhooks return 200 silently
- [ ] All steps logged with slog