# 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