cast-ghl-plugin/GHL_API_REFERENCE.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

301 lines
7.4 KiB
Markdown

# GHL Conversation Provider — API Reference
**GHL API Base URL:** `https://services.leadconnectorhq.com`
**GHL Marketplace:** `https://marketplace.gohighlevel.com`
**API Version Header:** `Version: 2021-04-15`
---
## Overview
This document covers the GHL APIs used by the Cast GHL Provider bridge service. The bridge acts as a custom SMS Conversation Provider, receiving outbound webhooks from GHL and posting inbound messages + status updates back.
---
## Authentication
### OAuth 2.0
All GHL API calls require a Bearer token obtained via the OAuth 2.0 flow.
**Header:**
```
Authorization: Bearer <access_token>
Version: 2021-04-15
```
Access tokens expire after ~24 hours. Use the refresh token to obtain a new one.
---
## OAuth Endpoints
### 1. Authorization URL (user-facing redirect)
```
GET https://marketplace.gohighlevel.com/oauth/chooselocation?
response_type=code&
redirect_uri={REDIRECT_URI}&
client_id={CLIENT_ID}&
scope={SCOPES}
```
| Param | Description |
|-------|-------------|
| `response_type` | Always `code` |
| `redirect_uri` | Your OAuth callback URL (must match app config) |
| `client_id` | From GHL Marketplace app settings |
| `scope` | Space-separated list of scopes |
**Required scopes:**
```
conversations/message.write conversations/message.readonly conversations.write conversations.readonly contacts.readonly contacts.write
```
After authorization, GHL redirects to: `{redirect_uri}?code={authorization_code}`
### 2. Token Exchange
**`POST /oauth/token`**
Exchange an authorization code for access + refresh tokens.
```
Content-Type: application/x-www-form-urlencoded
client_id={CLIENT_ID}&
client_secret={CLIENT_SECRET}&
grant_type=authorization_code&
code={CODE}&
redirect_uri={REDIRECT_URI}
```
**Response — 200 OK:**
```json
{
"access_token": "eyJhbGc...",
"refresh_token": "def50200...",
"expires_in": 86400,
"token_type": "Bearer",
"locationId": "GKAWb4yu7A4LSc0skQ6g",
"companyId": "GK12345...",
"userType": "Location"
}
```
### 3. Token Refresh
**`POST /oauth/token`**
```
Content-Type: application/x-www-form-urlencoded
client_id={CLIENT_ID}&
client_secret={CLIENT_SECRET}&
grant_type=refresh_token&
refresh_token={REFRESH_TOKEN}
```
**Response:** Same shape as token exchange.
---
## Webhook: ProviderOutboundMessage
GHL sends this webhook to your Delivery URL whenever a user sends an SMS from GHL (Conversations, Workflows, Bulk Actions, Mobile App).
**Method:** POST
**Signature header:** `x-wh-signature` (ECDSA P-256 + SHA-256, base64-encoded ASN.1 DER)
### Payload Schema
```json
{
"contactId": "GKBhT6BfwY9mjzXAU3sq",
"locationId": "GKAWb4yu7A4LSc0skQ6g",
"messageId": "GKJxs4P5L8dWc5CFUITM",
"type": "SMS",
"phone": "+15864603685",
"message": "The text message to be sent to the contact",
"attachments": ["https://example.com/image.png"],
"userId": "GK56r6wdJDrkUPd0xsmx"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `contactId` | string | GHL contact ID |
| `locationId` | string | GHL sub-account (location) ID |
| `messageId` | string | GHL message ID — use for status updates |
| `type` | string | `"SMS"` or `"Email"` |
| `phone` | string | Recipient phone in E.164 format |
| `message` | string | Message body to send |
| `attachments` | array | URLs of attached media (MMS) |
| `userId` | string | GHL user who sent the message |
### Webhook Signature Verification
1. Read the raw request body
2. Compute SHA-256 hash of the body bytes
3. Base64-decode the `x-wh-signature` header value
4. Verify the ECDSA ASN.1 DER signature using the public key from your GHL app settings
```go
hash := sha256.Sum256(body)
sigBytes, _ := base64.StdEncoding.DecodeString(signatureHeader)
valid := ecdsa.VerifyASN1(publicKey, hash[:], sigBytes)
```
The public key is provided in PEM format in your GHL Marketplace app settings under the webhook configuration.
---
## Conversation APIs
### Update Message Status
**`PUT /conversations/messages/{messageId}/status`**
Update the delivery status of an outbound message. Only the conversation provider's own marketplace app token can update status.
**Headers:**
```
Authorization: Bearer <access_token>
Content-Type: application/json
Version: 2021-04-15
```
**Request Body:**
```json
{
"status": "delivered"
}
```
| Status Value | Meaning |
|-------------|---------|
| `delivered` | Message delivered to recipient |
| `failed` | Message failed to send |
| `pending` | Message accepted, delivery in progress |
**Response — 200 OK:**
```json
{
"success": true
}
```
### Add Inbound Message
**`POST /conversations/messages/inbound`**
Post an inbound (received) message into a GHL conversation.
**Headers:**
```
Authorization: Bearer <access_token>
Content-Type: application/json
Version: 2021-04-15
```
**Request Body (SMS — default provider):**
```json
{
"type": "SMS",
"message": "Reply from the recipient",
"phone": "+639171234567"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | Yes | `"SMS"` for default provider |
| `message` | string | Yes | Inbound message body |
| `phone` | string | Yes | Sender's phone in E.164 format |
| `conversationProviderId` | string | No | Not required for default SMS provider |
**Response — 200 OK:**
```json
{
"conversationId": "...",
"messageId": "..."
}
```
**Note:** The `phone` number must match an existing contact in GHL. If no contact exists with that phone number, you may need to create one first via the Contacts API.
---
## Conversation Provider Setup
### Required Scopes
| Scope | Purpose |
|-------|---------|
| `conversations/message.write` | Outbound webhook events, inbound messages, status updates |
| `conversations/message.readonly` | Read message data, recordings, transcriptions |
| `conversations.write` | Create/update/delete conversations |
| `conversations.readonly` | Query conversations |
| `contacts.readonly` | Look up contacts by phone |
| `contacts.write` | Create contacts for unknown inbound numbers |
### Provider Configuration (in GHL Marketplace app)
1. Type: **SMS**
2. Name: **Cast SMS**
3. Delivery URL: `https://ghl.cast.ph/api/ghl/v1/webhook/messages`
4. Do NOT check "Is this a Custom Conversation Provider"
### Enabling the Provider (per sub-account)
After OAuth install:
1. Go to sub-account Settings → Phone Numbers → Advanced Settings
2. Select "Cast SMS" as the SMS Provider
3. Save
### Supported GHL Features
| Feature | Supported |
|---------|-----------|
| Conversations (web app) | Yes |
| Conversations (mobile app) | Yes |
| Workflows (SMS module) | Yes |
| Bulk Actions | Yes |
| Missed Call Text-Back | No (falls back to Twilio) |
| Review Requests | No (falls back to Twilio) |
| Internal SMS Notifications | No (falls back to Twilio) |
| Conversational AI | No (GHL limitation) |
---
## Rate Limits
GHL API rate limits vary by plan. The bridge should handle 429 responses gracefully:
- Retry after the `Retry-After` header value
- Max 3 retries per request
- Log rate limit events for monitoring
---
## Error Handling
GHL API errors return JSON with an error description:
```json
{
"statusCode": 401,
"message": "Unauthorized"
}
```
Common errors:
| Status | Cause | Action |
|--------|-------|--------|
| 401 | Token expired or invalid | Refresh token and retry |
| 403 | Insufficient scopes | Check app scope configuration |
| 404 | Message or contact not found | Log and skip |
| 422 | Invalid request body | Log and fix request format |
| 429 | Rate limited | Retry after Retry-After header |