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>
4.8 KiB
4.8 KiB
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)
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
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
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request)
Step 1: Verify signature
- Read
x-wh-signatureheader - Read request body
- Compute SHA-256 hash of the body
- Decode the base64 signature from the header
- Verify ECDSA signature using the public key
- If invalid: return 401 "invalid webhook signature"
Step 2: Parse payload
- Unmarshal body into
OutboundMessageWebhook - If
typeis not "SMS": return 200 (ignore non-SMS webhooks silently)
Step 3: Respond immediately
- Return 200 OK to GHL (don't make GHL wait for the SMS send)
Step 4: Process async (goroutine)
- Normalize phone number:
phone.ToLocal(webhook.Phone) - Call
castClient.SendSMS(ctx, localPhone, webhook.Message) - Get valid OAuth token:
oauthHandler.GetValidToken(ctx, webhook.LocationID) - On Cast success: call
ghlAPI.UpdateMessageStatus(ctx, token, webhook.MessageID, "delivered") - On Cast failure: call
ghlAPI.UpdateMessageStatus(ctx, token, webhook.MessageID, "failed") - Log result with
slog.Infoorslog.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.
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
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
SendSMScalled 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