Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
134 lines
4.8 KiB
Markdown
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
|