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>
7.4 KiB
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:
{
"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
{
"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
- Read the raw request body
- Compute SHA-256 hash of the body bytes
- Base64-decode the
x-wh-signatureheader value - Verify the ECDSA ASN.1 DER signature using the public key from your GHL app settings
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:
{
"status": "delivered"
}
| Status Value | Meaning |
|---|---|
delivered |
Message delivered to recipient |
failed |
Message failed to send |
pending |
Message accepted, delivery in progress |
Response — 200 OK:
{
"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):
{
"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:
{
"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)
- Type: SMS
- Name: Cast SMS
- Delivery URL:
https://ghl.cast.ph/api/ghl/v1/webhook/messages - Do NOT check "Is this a Custom Conversation Provider"
Enabling the Provider (per sub-account)
After OAuth install:
- Go to sub-account Settings → Phone Numbers → Advanced Settings
- Select "Cast SMS" as the SMS Provider
- 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-Afterheader value - Max 3 retries per request
- Log rate limit events for monitoring
Error Handling
GHL API errors return JSON with an error description:
{
"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 |