Skip to main content

GHL Webhooks

GoHighLevel fires webhook payloads when calls complete and when BDRs select dispositions. Client Portal processes these via two edge functions that handle deduplication, payload normalization, disposition inference, and automatic AI summarization.

Webhook Endpoints

Edge FunctionURL PathPurpose
ghl-call-webhook/functions/v1/ghl-call-webhookPrimary -- handles both internal and client calls. Routes by ghl_integrations.integration_type.
ghl-sales-webhook/functions/v1/ghl-sales-webhookLegacy -- internal sales calls only. Simpler payload handling. Being phased out.
Primary vs Legacy

ghl-call-webhook is the unified endpoint. It normalizes both camelCase and snake_case payloads, enriches data from the GHL Conversations API, handles deduplication, and routes to the correct table (sales_calls or calls). The legacy ghl-sales-webhook is simpler and does not perform GHL API enrichment.

Dual Webhook Problem

GHL fires two separate webhook hits per call:

  1. Call Completed workflow -- triggered when a call ends. Contains callStatus, contactId, locationId, but often lacks transcript and disposition.
  2. Disposition workflow -- triggered when a BDR manually selects a disposition in GHL. Contains disposition and source: "disposition_workflow".

Deduplication Strategy

Both webhooks include a messageId (from GHL's conversation message). Before inserting a call record, the handler checks for an existing record with the same external_call_id:

if (messageId) {
const { data: existing } = await supabase
.from("sales_calls")
.select("id")
.eq("external_call_id", messageId)
.maybeSingle();

if (existing) {
console.log(`Duplicate detected -- skipping`);
return { success: true, deduplicated: true };
}
}

For disposition webhooks specifically, the handler detects payload.source === "disposition_workflow" and updates the existing call record's disposition rather than creating a new one.

Call Status Normalization

Mixed Case Call Status

GHL callStatus values are MIXED CASE. You must normalize to lowercase before any comparison.

Observed callStatus values from production data:

Raw ValueCountNormalizedMeaning
Answered715answeredPerson picked up
No answer72no answerNo pickup
Busy25busyLine busy
Failed24failedCall failed
completed23completedPerson picked up
Voicemail4voicemailWent to voicemail
Missed1missedMissed call
Ringing1ringingRang but no answer

Critical: Answered and completed both mean someone picked up. The handler treats them identically:

const connected = status === "completed" || status === "answered";

Payload Normalization

GHL sends payloads in multiple formats depending on the trigger source. The normalizePayload() function handles all variants:

FieldGHL API FormatGHL Workflow FormatWorkflow Nested
LocationlocationIdlocation_idlocation.id
ContactcontactIdcontact_idcontact.id
Call StatuscallStatuscall_statustriggerData.callStatus
DurationcallDurationcall_durationtriggerData.duration
TranscriptbodytranscriptcustomData.transcript
Dispositiondispositioncall_dispositioncustomData.Last Disposition

The normalizer also checks triggerData, customData, and workflow nested objects.

Canonical Dispositions

Client Portal uses 9 canonical dispositions in Title Case:

DispositionDescriptionAuto-Follow-Up
No AnswerNo one picked up, line busy, call failedEngagement -> contacted
VoicemailVoicemail greeting detected1 day (first 3), then 7 days
GatekeeperReceptionist/assistant, decision-maker not reached2 days
Incorrect NumberWrong person, number not in serviceClosed lost
Not InterestedProspect explicitly declined14 days (nurturing)
Callback RequestedProspect explicitly said "call me back"2-3 days
Future PotentialEngaged conversation, no meeting booked7-14 days
Meeting BookedMeeting or demo scheduledEngagement -> meeting_set
Do Not ContactProspect asked not to be contactedClosed lost, do_not_contact = true

Disposition Mapping from GHL

const GHL_DISPOSITION_MAP: Record<string, string> = {
"no answer": "No Answer",
"voicemail": "Voicemail",
"gatekeeper": "Gatekeeper",
"incorrect number": "Incorrect Number",
"bad number": "Incorrect Number",
"not interested": "Not Interested",
"not engaging": "Not Interested",
"callback requested": "Callback Requested",
"neutral conversation": "Callback Requested",
"open conversation": "Future Potential",
"future potential": "Future Potential",
"meeting booked": "Meeting Booked",
"do not contact": "Do Not Contact",
};

Disposition Inference

When no BDR-confirmed disposition is available, the handler infers one using a priority chain:

Transcript Regex Patterns

const VM_PATTERN = /your call has been forwarded|voicemail|leave (a |your )?message|you[' ]?ve reached|please leave your name|not available.*tone|after the (beep|tone)/i;

const DNC_PATTERN = /\bdo not contact\b|\bremove me\b|\btake me off\b|\bstop calling\b|\bnever call\b/i;

const INCORRECT_NUMBER_PATTERN = /\bnot the person\b|\bwrong number\b|\bwrong person\b|\bno one here by that name\b|\bdoesn'?t work here\b/i;

const NOT_INTERESTED_PATTERN = /\bno[,.]?\s*thanks\b|\bnot interested\b|\bnot in that space\b/i;

const CALLBACK_PATTERN = /call (me |us )?back|call another time|call later|try (me |again )?(later|another|next|tomorrow)|not a good time|bad time|can'?t talk (right )?now/i;

GHL API Enrichment

The ghl-call-webhook handler enriches sparse webhook data by calling the GHL Conversations API. The enrichment chain:

contactId -> /conversations/search -> conversationId
-> /conversations/{id}/messages -> find TYPE_CALL message
-> Extract: duration, status, direction, from, to
-> If duration > 15s: /transcription/download -> transcript text

This enrichment fills in data that GHL workflow webhooks omit (duration, direction, transcript).

Auto-Trigger: AI Summarization

When a webhook produces a transcript longer than 50 characters and does NOT match the voicemail pattern, the handler automatically calls summarize-sales-call:

const flatTranscript = transcript ? flattenTranscript(transcript) : "";
const hasRealTranscript =
flatTranscript.length > 50 &&
!VM_PATTERN.test(flatTranscript);

if (hasRealTranscript) {
const aiRes = await fetch(`${supabaseUrl}/functions/v1/summarize-sales-call`, {
method: "POST",
headers: {
Authorization: `Bearer ${supabaseKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ call_id: callData.id, transcript: flatTranscript }),
});
}

The AI summarization response includes a refined disposition that overwrites the regex-inferred disposition.

Transcript Flattening

GHL transcripts arrive in two formats. The handler normalizes both:

  1. Plain text with timestamps: "00:01: Hello?\n00:04: Hi, this is..." -- timestamps are stripped.
  2. JSON array: [{"transcript": "Hello?"}, {"transcript": "Hi..."}] -- entries are concatenated.
function flattenTranscript(raw: string): string {
const trimmed = raw.trim();
if (trimmed.startsWith("[")) {
const arr = JSON.parse(trimmed);
return arr.map((s) => s.transcript || "").join(" ");
}
return trimmed.replace(/\d{2}:\d{2}:\s*/g, "");
}

Routing: Internal vs Client

The ghl-call-webhook determines routing by looking up the location_id in ghl_integrations:

For internal calls, if no sales_prospect matches the GHL contact ID, the handler attempts a fallback: matching by Market Name custom field to route the call to a client's calls table instead.

Debug Logging

Every incoming webhook payload is logged to webhook_debug_log:

const { data: logRow } = await supabase.from("webhook_debug_log").insert({
function_name: "ghl-call-webhook",
raw_payload: rawPayload,
result: "received",
}).select("id").single();

The result is updated after processing completes, making it easy to debug failed or unexpected webhook deliveries.