← Documentation to help get you started

Go High Level Integration

Updated October 27, 2026

Connecting GoHighLevel to demo builds

How to wire a GoHighLevel (GHL) sub-account so that when a prospect says yes, GHL asks ChatOpsPro to build a demo from the prospect's website, and the result — test page link plus which questions it couldn't answer — lands back on the GHL contact, ready for the delivery email.

API reference: Partner API. How demo builds work: Demo builds.

GHL renames menus and actions from time to time. The names below are the ones GHL uses at the time of writing; if one has moved, search the action list for the closest match ("Custom Webhook", "Inbound Webhook").

How it fits together

Prospect says yes (tag, pipeline stage, form…)
  └─ GHL workflow 1 ── Custom Webhook ──▶ POST /v1/demos  (202, building)
                                               │  a few minutes
                                               ▼
  GHL workflow 2 ◀── Inbound Webhook ── ChatOpsPro POSTs the result
     ├─ status = success → save fields → delivery email (+ follow-up sequence)
     └─ status = failed  → notify you (no email to the prospect)

The two halves are matched by externalRef, which workflow 1 sets to the GHL contact id, and by contactEmail, which ChatOpsPro echoes back.

Before you start

You need, from ChatOpsPro (Agency console → Demo builds):

  1. An API key: Demo builds → API keys → New API key. Name it "GoHighLevel", pick a lifetime (up to 6 months — put a calendar reminder to rotate it before it expires), leave the callback URL empty for now. Copy the key (cop_live_…) and the webhook secret; they are shown once.
  2. Optionally, Page wording: your banner text, the text above the unanswered questions, and your booking link. The booking button appears on every demo page once a link is set.

In GHL you need permission to create workflows and custom fields. Custom Webhook (action) and Inbound Webhook (trigger) are premium workflow features in GHL; check they are enabled for the sub-account.

Step 1 — Custom fields in GHL

Settings → Custom Fields → add these contact fields (single-line text unless noted):

Field name Holds
Demo Status success or failed
Demo Test Page URL the preview link
Demo Unanswered Questions (multi-line) one question per line
Demo Answered Count e.g. 3
Demo Unanswered Count e.g. 2
Demo Failure Reason e.g. crawl_blocked
Demo Expires the deletion date
Demo ID ChatOpsPro's build id, for support

Per-question fields are optional; the callback also carries answers.insurance, answers.openings, answers.ages, answers.telehealth and answers.fees, each answered or not_answered.

Step 2 — Workflow 2 first: receive the result

Build the receiving workflow first, because its URL is needed by the API key.

  1. Automation → Workflows → Create → Start from scratch. Name it "Demo build — result".

  2. Add trigger → Inbound Webhook. GHL shows a webhook URL (https://services.leadconnectorhq.com/hooks/…). Copy it.

  3. GHL needs one sample request before it lets you map fields. Send the sample callback below to that URL (see Testing with curl, step 7), then click Fetch sample requests in the trigger and pick it.

  4. Back in ChatOpsPro: API keys → Edit on the GoHighLevel key → paste the GHL URL as the callback URL → Save. Every build made with this key now reports here.

  5. Add an action that ties the request to the contact. The dependable route is Create/Update Contact with Email = {{inboundWebhookRequest.contactEmail}}: GHL matches an existing contact on email, so this updates the prospect rather than creating a duplicate. (This is why workflow 1 sends the contact's email.)

  6. In the same action, or a following Update Contact Field action, map:

    GHL field Value
    Demo Status {{inboundWebhookRequest.status}}
    Demo Test Page URL {{inboundWebhookRequest.testPageUrl}}
    Demo Unanswered Questions {{inboundWebhookRequest.unansweredSummary}}
    Demo Answered Count {{inboundWebhookRequest.answeredCount}}
    Demo Unanswered Count {{inboundWebhookRequest.unansweredCount}}
    Demo Failure Reason {{inboundWebhookRequest.reason}}
    Demo Expires {{inboundWebhookRequest.expiresAt}}
    Demo ID {{inboundWebhookRequest.id}}
  7. If/Else on {{inboundWebhookRequest.status}}:

    • is success → Send Email (the delivery email: the test page link from the custom field, the unanswered questions, your booking link) → then add the contact to the follow-up sequence.
    • else → Internal Notification to yourself with the business name, website, {{inboundWebhookRequest.reason}} and {{inboundWebhookRequest.detail}}. No email goes to the prospect.
  8. Save and Publish.

Step 3 — Workflow 1: start the build

  1. Create a workflow "Demo build — request".

  2. Trigger: whatever "prospect said yes" means for you — a tag added (e.g. demo-yes), a pipeline stage change, a form submission, or a reply classified as positive.

  3. Optional but recommended: If/Else "Website is not empty". A contact with no website would be refused (400 invalid_request), so route those to an internal notification instead.

  4. Action → Custom Webhook:

    Setting Value
    Event / method POST
    URL https://chatops.encoreinteractive.ca/chatbot/v1/demos
    Authorization Bearer token → your cop_live_… key (or a header Authorization: Bearer cop_live_…)
    Headers Content-Type: application/json
    Body (raw JSON) below
    {
      "businessName": "{{contact.company_name}}",
      "websiteUrl": "{{contact.website}}",
      "contactEmail": "{{contact.email}}",
      "externalRef": "{{contact.id}}",
      "industry": "therapy",
      "region": "US"
    }
    

    region picks the crisis lines the demo shows (US or CA). Leave callbackUrl out — the key's callback URL from step 2 is used.

  5. Save and Publish.

That is the whole integration. A retried or double-fired workflow is harmless: the same externalRef (contact id) returns the build already made instead of starting another. To deliberately rebuild a contact's demo, delete the old one first (Agency console, or DELETE /v1/demos/{id}).

What arrives in GHL

{
  "event": "demo.completed",
  "deliveryId": "8d1c…",
  "id": "3f0b…",
  "externalRef": "<GHL contact id>",
  "status": "success",
  "businessName": "Calm Harbor Therapy",
  "websiteUrl": "https://calmharbortherapy.com",
  "contactEmail": "owner@calmharbortherapy.com",
  "industry": "therapy",
  "region": "US",
  "testPageUrl": "https://chatops.encoreinteractive.ca/chatbot/test-page/…",
  "screenshotUrl": "https://chatops.encoreinteractive.ca/chatbot/uploads/…/demo-homepage.jpg",
  "pageCount": 12,
  "questions": [
    { "key": "insurance", "question": "Do you take insurance? Which plans do you accept?", "answered": false },
    { "key": "openings", "question": "Are you accepting new clients right now?", "answered": true, "answer": "…" }
  ],
  "answers": { "insurance": "not_answered", "openings": "answered", "ages": "answered", "telehealth": "answered", "fees": "not_answered" },
  "answeredCount": 3,
  "unansweredCount": 2,
  "unansweredSummary": "Do you take insurance? Which plans do you accept?\nHow much does a session cost?",
  "createdAt": "2026-10-07T18:02:11Z",
  "completedAt": "2026-10-07T18:05:40Z",
  "expiresAt": "2026-10-21T18:02:11Z"
}

A failed build has "status": "failed", a reason (crawl_blocked, no_content, screenshot_failed, ingest_failed, timeout, internal) and a detail, and no testPageUrl.

Delivery: within about 10 seconds of the build finishing. If GHL does not answer 2xx, ChatOpsPro retries after 1 min, 5 min, 15 min, 1 h, 3 h and 6 h. Each demo in the Agency console shows "callback sent" or "callback failed".

Signature: every callback carries X-ChatOps-Signature. GHL's inbound webhook cannot check it; that is acceptable here because the payload holds nothing secret beyond the demo link, which is unlisted and expires. A receiver of your own should verify it (tools/partner-api/verify-signature.js).

Testing with curl

Run from Git Bash, macOS or Linux. In PowerShell, call curl.exe (plain curl is an alias for Invoke-WebRequest there) and swap the \ line breaks for a backtick — or use tools/partner-api/test-demos.ps1, which does all of this.

Point the build at a real practice website you are comfortable crawling.

BASE=https://chatops.encoreinteractive.ca/chatbot
KEY=cop_live_PASTE_YOUR_KEY
SITE=https://www.some-practice.com
REF=ghl-test-$(date +%s)
GHL_HOOK=https://services.leadconnectorhq.com/hooks/PASTE_YOUR_INBOUND_WEBHOOK

1. The API is up (no key needed) — expect the OpenAPI document:

curl -s "$BASE/v1/openapi.yaml" | head -5

2. Start a build — expect HTTP 202 and "status":"building":

curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE/v1/demos" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{\"businessName\":\"Test Practice\",\"websiteUrl\":\"$SITE\",\"contactEmail\":\"you@example.com\",\"externalRef\":\"$REF\",\"industry\":\"therapy\",\"region\":\"US\"}"

3. Same request again — expect HTTP 200 and the same id (no second build):

curl -s -w "\nHTTP %{http_code}\n" -X POST "$BASE/v1/demos" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d "{\"websiteUrl\":\"$SITE\",\"externalRef\":\"$REF\"}"

4. Wait for the result — polls every 15 s until success or failed (a few minutes; add ~1 minute if no build has run recently):

while :; do
  R=$(curl -s "$BASE/v1/demos?externalRef=$REF" -H "Authorization: Bearer $KEY")
  S=$(printf '%s' "$R" | sed -n 's/.*"status":"\([a-z]*\)".*/\1/p')
  echo "$(date +%T) $S"
  [ "$S" != "building" ] && break
  sleep 15
done
printf '%s\n' "$R"

On success, open the testPageUrl in a browser. If your key has the GHL callback URL set, the same JSON has now been POSTed to workflow 2 — check the contact.

5. The header GHL tools sometimes need instead of Bearer — expect HTTP 200:

curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/v1/demos?externalRef=$REF" -H "X-API-Key: $KEY"

6. Things that must be refused:

# no key → 401 unauthorized
curl -s -w "  HTTP %{http_code}\n" "$BASE/v1/demos?externalRef=$REF"
# wrong key → 401 unauthorized
curl -s -w "  HTTP %{http_code}\n" "$BASE/v1/demos?externalRef=$REF" -H "Authorization: Bearer cop_live_notARealKey0000000000000000"
# no website (what an empty {{contact.website}} sends) → 400 invalid_request
curl -s -w "  HTTP %{http_code}\n" -X POST "$BASE/v1/demos" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"externalRef":"neg-1","websiteUrl":""}'
# plain-http callback → 400 invalid_request
curl -s -w "  HTTP %{http_code}\n" -X POST "$BASE/v1/demos" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d "{\"externalRef\":\"neg-2\",\"websiteUrl\":\"$SITE\",\"callbackUrl\":\"http://example.com/x\"}"

7. Prime GHL's inbound webhook with a sample result — run this while the Inbound Webhook trigger is waiting for a sample (step 2.3), so GHL can offer the fields for mapping. It is a made-up payload in exactly the real shape; use a real contact's email so the Create/Update Contact action finds them:

curl -s -w "\nHTTP %{http_code}\n" -X POST "$GHL_HOOK" -H "Content-Type: application/json" -d '{
  "event":"demo.completed","deliveryId":"sample-1","id":"sample-build-1",
  "externalRef":"sample-contact-id","status":"success",
  "businessName":"Calm Harbor Therapy","websiteUrl":"https://calmharbortherapy.com",
  "contactEmail":"you@example.com","industry":"therapy","region":"US",
  "testPageUrl":"https://chatops.encoreinteractive.ca/chatbot/test-page/sample",
  "screenshotUrl":"https://chatops.encoreinteractive.ca/chatbot/uploads/sample/demo-homepage.jpg",
  "pageCount":12,
  "questions":[
    {"key":"insurance","question":"Do you take insurance? Which plans do you accept?","answered":false},
    {"key":"openings","question":"Are you accepting new clients right now?","answered":true,"answer":"Yes, we have openings this month."},
    {"key":"ages","question":"What ages do you work with? Do you see children or teens?","answered":true,"answer":"We see adults and teens 13+."},
    {"key":"telehealth","question":"Do you offer online or telehealth sessions?","answered":true,"answer":"Yes, sessions are available online."},
    {"key":"fees","question":"How much does a session cost?","answered":false}],
  "answers":{"insurance":"not_answered","openings":"answered","ages":"answered","telehealth":"answered","fees":"not_answered"},
  "answeredCount":3,"unansweredCount":2,
  "unansweredSummary":"Do you take insurance? Which plans do you accept?\nHow much does a session cost?",
  "createdAt":"2026-10-07T18:02:11Z","completedAt":"2026-10-07T18:05:40Z","expiresAt":"2026-10-21T18:02:11Z"}'

To test the failed branch of workflow 2, send the same request with "status":"failed","reason":"crawl_blocked","detail":"robots.txt disallows /" and without testPageUrl.

8. Clean up — expect HTTP 204; the test page link then returns 404:

ID=$(curl -s "$BASE/v1/demos?externalRef=$REF" -H "Authorization: Bearer $KEY" | sed -n 's/^{"id":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X DELETE "$BASE/v1/demos/$ID" -H "Authorization: Bearer $KEY"

Troubleshooting

What you see Why
Workflow 1 errors with 401 Key mistyped, revoked or expired — check API keys in the Agency console.
Workflow 1 errors with 400 Usually an empty {{contact.website}}; add the "Website is not empty" check.
Workflow 1 errors with 429 50 demos per agency per 24 h, or the same site twice within 10 minutes.
Demo built, contact not updated Callback URL not set on the key, or Create/Update Contact matched no contact (the email differs). Check the demo's callback badge in the Agency console.
Callback badge "failed" GHL did not answer 2xx for ~10 h of retries — workflow 2 unpublished, or the webhook URL changed. Hover the badge for the last error.
Prospect got an email with no link The If/Else isn't checking status; failed builds have no testPageUrl.