Skip to main content

Debugging GHL Issues

Troubleshooting guide for GoHighLevel integration problems -- webhooks, sync, dispositions, and field mapping.

Webhook Not Firing

Symptoms: Calls made in GHL do not appear in Client Portal. No entries in webhook_debug_log.

Checklist:

  1. Check GHL workflow configuration:

    • Log into the GHL location
    • Navigate to Automation > Workflows
    • Find the Call Completed and Disposition workflows
    • Verify they are active (not paused or draft)
    • Verify the webhook action points to the correct Supabase edge function URL
  2. Verify webhook URL format:

    https://<project-id>.supabase.co/functions/v1/ghl-call-webhook

    or

    https://<project-id>.supabase.co/functions/v1/ghl-sales-webhook
  3. Check edge function is deployed:

    curl -X POST https://<supabase-url>/functions/v1/ghl-call-webhook \
    -H "Content-Type: application/json" \
    -d '{}'

    If you get a 404, the function is not deployed. Deploy it:

    supabase functions deploy ghl-call-webhook
  4. Check JWT verification: GHL cannot send Supabase JWTs. Ensure verify_jwt = false is set in supabase/config.toml:

    [functions.ghl-call-webhook]
    verify_jwt = false

    [functions.ghl-sales-webhook]
    verify_jwt = false
  5. Check edge function logs: Go to Supabase Dashboard > Edge Functions > select function > Logs. Look for errors, especially 4xx/5xx responses.

Duplicate Calls

Symptoms: The same call appears twice in sales_calls or calls table.

Cause: GHL fires 2 webhook hits per call -- one from the Call Completed workflow and one from the Disposition workflow. Both carry the same external_call_id.

Verify the issue:

SELECT external_call_id, count(*)
FROM sales_calls
WHERE external_call_id IS NOT NULL
GROUP BY external_call_id
HAVING count(*) > 1;

Fix:

  • The webhook handler should check for existing records before inserting:
    SELECT id FROM sales_calls WHERE external_call_id = $1 LIMIT 1;
  • If duplicates already exist, clean them up:
    -- Find and delete duplicates, keeping the earliest record
    DELETE FROM sales_calls
    WHERE id IN (
    SELECT id FROM (
    SELECT id, ROW_NUMBER() OVER (
    PARTITION BY external_call_id
    ORDER BY created_at ASC
    ) AS rn
    FROM sales_calls
    WHERE external_call_id IS NOT NULL
    ) ranked
    WHERE rn > 1
    );

Wrong Disposition

Symptoms: Call shows "No Answer" when someone clearly answered, or disposition does not match the transcript.

Possible causes:

1. callStatus not normalized

GHL sends callStatus in mixed case. If the webhook handler compares without normalizing:

// Bug: "Answered" !== "answered"
if (payload.callStatus === 'answered') { ... }

// Fix: always normalize
const status = payload.callStatus?.toLowerCase();
if (status === 'answered' || status === 'completed') { ... }

2. "Answered" vs "completed" treated differently

Both Answered and completed mean someone picked up the phone. They must be treated identically:

GHL ValueMeaning
Answered (715 historical)Someone picked up
completed (23 historical)Someone picked up

3. Regex inference too aggressive

The regex-based disposition inference runs before AI summarization. If it matches incorrectly, the initial disposition will be wrong. The AI summary should overwrite it, but check if the AI summarization was triggered (transcript must be >100 chars).

Verify AI summary ran:

SELECT id, disposition, ai_summary, transcript
FROM sales_calls
WHERE id = '<call-id>';

If ai_summary is NULL and transcript has >100 chars, the AI summarization may have failed. Check summarize-sales-call edge function logs.

Sync Failures

Symptoms: Prospects exist in Client Portal but are not synced to GHL. ghl_contact_id is NULL.

Checklist:

  1. Check API token validity:

    SELECT location_id, api_token, created_at
    FROM ghl_integrations
    WHERE location_id = '<location-id>';

    Test the token:

    curl -H "Authorization: Bearer <api-token>" \
    -H "Version: 2021-07-28" \
    https://services.leadconnectorhq.com/contacts/ \
    -G -d "locationId=<location-id>&limit=1"

    If you get 401, the token is expired or invalid.

  2. Check custom field mapping:

    SELECT custom_field_mapping
    FROM ghl_integrations
    WHERE location_id = '<location-id>';

    Verify the JSON maps Client Portal field names to valid GHL custom field IDs. GHL custom field IDs look like <location-id>.customField.<field-id>.

  3. Check prospect has ghl_location_id:

    SELECT id, company_name, ghl_contact_id, ghl_synced_at, ghl_location_id
    FROM sales_prospects
    WHERE ghl_contact_id IS NULL
    AND ghl_location_id IS NOT NULL;

    If ghl_location_id is NULL, the prospect is not linked to a GHL account.

  4. Trigger sync manually:

    curl -X POST \
    https://<supabase-url>/functions/v1/sync-prospects-to-ghl \
    -H "Authorization: Bearer <service-role-key>"

Debug Table

Raw webhook payloads are logged to webhook_debug_log for debugging:

SELECT *
FROM webhook_debug_log
ORDER BY created_at DESC
LIMIT 10;

This shows the full request body, headers, and processing result for each webhook invocation. Use this to understand exactly what GHL is sending.

Mixed Case Field Names

GHL uses different casing conventions depending on the context:

ContextConventionExample
GHL Workflows (webhook payloads)snake_casecall_status, contact_id
GHL REST API responsescamelCasecallStatus, contactId

The webhook handler must account for both conventions. Check which format the incoming payload uses before accessing fields.

GHL API Reference

DetailValue
Base URLhttps://services.leadconnectorhq.com
API Version headerVersion: 2021-07-28
AuthAuthorization: Bearer <api-token>
Rate limitsVaries by endpoint; check GHL docs