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

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

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

  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.

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