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 Function | URL Path | Purpose |
|---|---|---|
ghl-call-webhook | /functions/v1/ghl-call-webhook | Primary -- handles both internal and client calls. Routes by ghl_integrations.integration_type. |
ghl-sales-webhook | /functions/v1/ghl-sales-webhook | Legacy -- internal sales calls only. Simpler payload handling. Being phased out. |
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:
- Call Completed workflow -- triggered when a call ends. Contains
callStatus,contactId,locationId, but often lacks transcript and disposition. - Disposition workflow -- triggered when a BDR manually selects a disposition in GHL. Contains
dispositionandsource: "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
GHL callStatus values are MIXED CASE. You must normalize to lowercase before any comparison.
Observed callStatus values from production data:
| Raw Value | Count | Normalized | Meaning |
|---|---|---|---|
Answered | 715 | answered | Person picked up |
No answer | 72 | no answer | No pickup |
Busy | 25 | busy | Line busy |
Failed | 24 | failed | Call failed |
completed | 23 | completed | Person picked up |
Voicemail | 4 | voicemail | Went to voicemail |
Missed | 1 | missed | Missed call |
Ringing | 1 | ringing | Rang 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:
| Field | GHL API Format | GHL Workflow Format | Workflow Nested |
|---|---|---|---|
| Location | locationId | location_id | location.id |
| Contact | contactId | contact_id | contact.id |
| Call Status | callStatus | call_status | triggerData.callStatus |
| Duration | callDuration | call_duration | triggerData.duration |
| Transcript | body | transcript | customData.transcript |
| Disposition | disposition | call_disposition | customData.Last Disposition |
The normalizer also checks triggerData, customData, and workflow nested objects.
Canonical Dispositions
Client Portal uses 9 canonical dispositions in Title Case:
| Disposition | Description | Auto-Follow-Up |
|---|---|---|
| No Answer | No one picked up, line busy, call failed | Engagement -> contacted |
| Voicemail | Voicemail greeting detected | 1 day (first 3), then 7 days |
| Gatekeeper | Receptionist/assistant, decision-maker not reached | 2 days |
| Incorrect Number | Wrong person, number not in service | Closed lost |
| Not Interested | Prospect explicitly declined | 14 days (nurturing) |
| Callback Requested | Prospect explicitly said "call me back" | 2-3 days |
| Future Potential | Engaged conversation, no meeting booked | 7-14 days |
| Meeting Booked | Meeting or demo scheduled | Engagement -> meeting_set |
| Do Not Contact | Prospect asked not to be contacted | Closed 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:
- Plain text with timestamps:
"00:01: Hello?\n00:04: Hi, this is..."-- timestamps are stripped. - 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.
Related Documentation
- GHL Overview -- Architecture and API configuration
- GHL Prospect Sync -- Push/pull sync mechanics
- Anthropic AI -- AI call summarization details