Every recipe in this book follows a consistent structure:
A browser-based tool to test any GoHighLevel API endpoint. No Postman, no curl commands, no setup — just paste your token and go.
Set up your Private Integration Token and make your first authenticated API call.
Settings → Integrations → Private Integrations
API Cookbook)Authorization: Bearer YOUR_TOKEN Version: 2021-07-28 Content-Type: application/json
The token authenticates every request you make. The Version header is required on EVERY request — GHL uses API versioning to manage breaking changes. Content-Type tells GHL you're sending JSON.
Search your contact database using the API.
POST /contacts/search · Scope: contacts.readonly
POST https://services.leadconnectorhq.com/contacts/search // Headers Authorization: Bearer YOUR_TOKEN Version: 2021-07-28 // Body { "locationId": "YOUR_LOCATION_ID", "page": 1, "pageLimit": 20, "filters": [] }
Returns a contacts array with id, firstName, lastName, email, phone, and tags for each match.
POST /contacts/search is the recommended way to find contacts. The old GET /contacts/ endpoint is deprecated. Always use POST search.
POST /contacts/ · Scope: contacts.write
POST https://services.leadconnectorhq.com/contacts/ { "locationId": "YOUR_LOCATION_ID", "firstName": "John", "lastName": "Doe", "email": "john@example.com", "phone": "+1234567890", "tags": ["api-created", "cookbook-test"], "source": "API Cookbook" }
locationId is always required — it tells GHL which sub-account to create the contact in. Tags help you track API-created contacts. The source field shows where the contact came from in the GHL UI.
"api-created" so you can easily find and manage contacts created via the API.
POST /contacts/upsert · Scope: contacts.write
POST https://services.leadconnectorhq.com/contacts/upsert { "locationId": "YOUR_LOCATION_ID", "firstName": "John", "email": "john@example.com", "tags": ["returning-lead"] }
If a contact with that email exists, it updates the record. If not, it creates a new one. This is THE pattern for avoiding duplicates. It matches on email or phone.
GET /contacts/search/duplicate · Scope: contacts.readonly
GET /contacts/search/duplicate?locationId=XXX&email=john@example.com
Returns matching contacts by email or phone. Use this before creating contacts if you need to handle duplicates with custom logic (e.g., merging fields, prompting the user).
upsert (Recipe 1.4)duplicate search first, then decide
A workflow that searches for duplicate contacts and auto-tags them as "new" or "returning."
POST /contacts/searchPOSThttps://services.leadconnectorhq.com/contacts/searchAuthorization (Bearer token), Version (2021-07-28), Content-Type (application/json){
"locationId": "{{location.id}}",
"filters": [{
"field": "email",
"operator": "eq",
"value": "{{contact.email}}"
}]
}
"returning-lead" / Not Found → Tag "new-lead"Form submission automatically creates an opportunity in your sales pipeline.
GET /opportunities/pipelines (fetch pipeline & stage IDs)POST /opportunities/ (create opportunity)GET /opportunities/pipelines?locationId={{location.id}}pipelineId and stageId from responsePOST /opportunities/{
"pipelineId": "...",
"locationId": "...",
"name": "{{contact.name}} - New Deal",
"status": "open",
"contactId": "{{contact.id}}",
"pipelineStageId": "...",
"monetaryValue": 500
}
Check calendar availability and book an appointment — all inside GHL workflows.
GET /calendars/{id}/free-slotsPOST /calendars/events/appointmentsPOST /conversations/messages (Version: 2021-04-15!)"book-now"GET free-slots with startDate, endDate, timezonePOST appointment with calendarId, contactId, startTimePOST SMS confirmation2021-04-15 for the Conversations endpoint! Using the wrong version is the #1 source of 400 errors.
When an appointment completes, automatically advance the contact's pipeline stage.
GET /opportunities/searchPUT /opportunities/{id}POST /contacts/{id}/notes"showed"contactId, status=openPUT opportunity with new pipelineStageIdPOST note logging the stage change with timestampA multi-workflow system: form → dedup → opportunity → booking → pipeline advance.
POST /contacts/search · POST /contacts/upsert · POST /contacts/{id}/tags · POST /opportunities/ · PUT /opportunities/{id} · GET /opportunities/search · GET /calendars/{id}/free-slots · POST /calendars/events/appointments · POST /conversations/messages
Location: Workflow → Add Action → Send Data → Custom Webhook
Cost: $0.01/execution (100 free included)
| Header | Value | Notes |
|---|---|---|
Authorization | Bearer YOUR_TOKEN | Always required |
Version | 2021-07-28 | ⚠️ 2021-04-15 for Conversations |
Content-Type | application/json | For POST/PUT requests |
| # | Method | Endpoint | Use |
|---|---|---|---|
| 1 | POST | /contacts/search | Find contacts |
| 2 | POST | /contacts/upsert | Create/update contact |
| 3 | POST | /contacts/{id}/tags | Add tags |
| 4 | POST | /contacts/{id}/notes | Create notes |
| 5 | POST | /opportunities/ | Create deal |
| 6 | PUT | /opportunities/{id} | Update stage/value |
| 7 | GET | /opportunities/search | Find deals |
| 8 | GET | /calendars/{id}/free-slots | Check availability |
| 9 | POST | /calendars/events/appointments | Book appointment |
| 10 | POST | /conversations/messages | Send SMS/Email ⚠️ |
Different endpoints require different Version headers!
| Endpoint Group | Version Header |
|---|---|
| Most endpoints | 2021-07-28 |
| Conversations endpoints | 2021-04-15 |
| Calendar endpoints | 2021-04-15 |
Using the wrong version header is the #1 cause of mysterious 400 errors.
When in doubt, check the official docs for each endpoint's required version.
Send SMS through any carrier (not Twilio), receive replies back in GHL conversations.
POST /contacts/search (find contact by phone)POST /conversations/ (create conversation if needed)POST /conversations/messages/inbound (log message, Version: 2021-04-15)Auto-enrich and score leads, then route to the appropriate pipeline stage based on score.
POST /locations/{locationId}/customFields — create score fieldsPUT /contacts/{contactId} — write score backPOST /contacts/{id}/tags — tier tag: hot/warm/coldPOST /opportunities/ — create in appropriate stageContactCreate eventPUT /contacts/{id} → write lead_score custom fieldPOST /opportunities/ (Qualified stage) + enroll in hot-lead workflow"long-term-drip" onlyNightly batch job that enriches and scores your entire contact database.
POST /contacts/search: filter enrichment_status != "complete"PUT /contacts/{id}"enrichment-failed" and continue to nextAn AI phone agent that checks your GHL calendar and books appointments mid-call.
GET /contacts/{contactId} — fetch contact for callGET /calendars/{id}/free-slots — mid-call availability checkPOST /calendars/events/appointments — book the slotPOST /conversations/messages — SMS confirmationPUT /contacts/{id} — write call outcome"call-now" → Custom Webhook to n8n"check_availability" → n8n → GET free-slots → return formatted slots"book_appointment" → n8n → POST appointmentPUT contacts with call_outcome, POST tags, update pipelineSame appointment-setting pattern as Recipe 3.4, but using Retell's Custom LLM WebSocket interface.
| Feature | Vapi | Retell |
|---|---|---|
| Latency | ~1–2s | Sub-800ms |
| Voice Options | ElevenLabs, PlayHT | Built-in voices |
| Setup | Simpler (REST tools) | Custom LLM required |
| Integration | n8n webhook tools | WebSocket + n8n |
| Pricing | Per-minute | Per-minute |
Both use n8n as middleware to GHL. The core pattern is identical:
Endpoints: Same as Recipe 3.4
Reference: Configuring n8n to work with the GHL API
Method: POST / GET / PUT / DELETE URL: https://services.leadconnectorhq.com/{endpoint} Authentication: Header Auth Name: Authorization Value: Bearer {{$credentials.ghlToken}} Headers: Version: 2021-07-28 Content-Type: application/json Body: JSON (specify parameters)
Never hardcode tokens. Use n8n's Credentials Store:
Authorization / Value: Bearer YOUR_TOKEN{{$credentials.ghlToken}}| Pattern | Nodes |
|---|---|
| Webhook → API Call | Webhook → HTTP Request → Respond to Webhook |
| Scheduled Sync | Schedule → HTTP Request → Supabase |
| Event Processing | Webhook → Switch → Multiple HTTP Requests |
| Batch Processing | Schedule → HTTP Request → SplitInBatches → Wait → HTTP Request |
Use the IF node to check HTTP status codes after every API call:
// Check for success IF {{ $json.statusCode }} == 200 → Continue ELSE → Error Handler (log to Supabase, retry, or alert)
Form submission → Sub-account creation → Custom field setup → User login → Welcome SMS. Full automated onboarding.
POST /locations/ — create sub-account (Agency Pro $497 plan required)POST /oauth/locationToken — generate token for new accountPOST /locations/{id}/customFields — setup fieldsPOST /contacts/ — create client contactPOST /users/ — create user loginPOST /conversations/messages — welcome SMSPOST /locations/ with companyId, name, timezonePOST /oauth/locationToken → get new location tokenPOST /locations/{id}/customFields → create standard fieldsPOST /users/ → create user loginPOST /conversations/messages → welcome SMSLink Custom Objects to Opportunities via the Associations API.
POST /associations/relations — create the linkGET /associations/relations/{recordId} — verifyWorkflow 1: Form → Find/Create Opportunity → Create Associated Record (Account object)
Workflow 2: Object Created trigger → Stamp self-ID → Custom Webhook:
POST /associations/relations { "firstRecordId": "account_id", "secondRecordId": "opportunity_id", "associationId": "static_association_id", "locationId": "..." }
URL: https://services.leadconnectorhq.com/{endpoint} Method: POST / GET / PUT / DELETE Headers: Authorization: Bearer {{connection.token}} Version: 2021-07-28 Content-Type: application/json Body type: Raw → JSON Request content: (your JSON body)
Sync GHL data to Supabase, calculate KPIs, display on a live dashboard.
POST /contacts/search — sync contactsGET /opportunities/search — sync pipelineGET /calendars/events — sync appointmentscontacts, opportunities, kpi_snapshotsPOST /contacts/search → upsert to SupabaseGET opportunities → upsert to Supabasewin_rate, revenue, avg_response_timeAI classifies every inbound message as booking request, inquiry, complaint, or spam — and auto-responds when confident.
InboundMessage eventGET /conversations/{id}/messages — get contextPOST /conversations/messages — auto-replyPOST /contacts/{id}/tags — classification tagghl-inbound-handler receives webhookbooking_request / inquiry / complaint / spamPOST auto-reply via Conversations API"needs-human-review"booking_request: Tag "book-now" → triggers the auto-booking workflow from Recipe 2.3!Manage OAuth tokens for 100+ sub-accounts with automatic refresh and failure alerting.
POST /oauth/token — refresh tokensPOST /oauth/locationToken — generate location tokensghl_tokens with columns:location_id, access_token, refresh_token, expires_at, statusPOST /oauth/token with refresh_token"failed" → Slack alertCopy-paste starter for any GHL + Supabase integration
const GHL_BASE = 'https://services.leadconnectorhq.com'; async function ghlRequest( endpoint: string, token: string, options: RequestInit = {} ) { const response = await fetch( `${GHL_BASE}${endpoint}`, { ...options, headers: { 'Authorization': `Bearer ${token}`, 'Version': '2021-07-28', 'Content-Type': 'application/json', ...options.headers, }, } ); if (!response.ok) { throw new Error( `GHL API Error: ${response.status}` ); } return response.json(); }
import { createClient } from '@supabase/supabase-js'; Deno.serve(async (req) => { const body = await req.json(); const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_KEY')! ); // 1. Store the event await supabase .from('ghl_events') .insert({ payload: body }); // 2. Enqueue for processing await supabase.rpc('pgmq_send', { queue: 'ghl_events', message: body }); // 3. Return 200 immediately! return new Response( JSON.stringify({ status: 'queued' }), { status: 200 } ); });
Claude Desktop connected to your live GHL account via MCP (Model Context Protocol).
Contacts: search, create, update, delete, add tags, add notes
Calendars: list, get free slots, book appointments
Opportunities: search, create, update, change status
Conversations: search, get messages, send messages
Workflows: list, add contact to workflow
Claude audits your entire pipeline, flags stale deals, and cross-references with calendar data.
"Search all open opportunities. Flag any that have
been stuck in the same stage for more than 14 days.
For each stale deal, cross-reference with the
calendar — does the contact have an upcoming
appointment? Give me a prioritized action list."
opportunities/search → gets all open dealsClaude reads and summarizes all recent conversations, giving you a quick status overview.
"Get the last 20 conversations. For each one,
summarize the key points and classify the status
as: active, waiting-for-reply, or closed.
Group them by status and highlight any that
need immediate attention."
A structured report grouped by status:
| Category | Event | Fires When |
|---|---|---|
| Contact | ContactCreate | New contact created |
| ContactUpdate | Contact fields changed | |
| ContactDelete | Contact removed | |
| ContactTagUpdate | Tags added/removed | |
| ContactDndUpdate | DND status changed | |
| Conversation | InboundMessage | Message received |
| OutboundMessage | Message sent | |
| ConversationUnreadUpdate | Unread count changed | |
| Appointment | AppointmentCreate | Appointment booked |
| AppointmentUpdate | Appointment modified | |
| AppointmentDelete | Appointment cancelled | |
| Opportunity | OpportunityCreate | Deal created |
| OpportunityUpdate | Deal modified | |
| OpportunityStageUpdate | Stage changed | |
| OpportunityStatusUpdate | Won/Lost/Open changed | |
| OpportunityMonetaryValueUpdate | Deal value changed | |
| Notes/Tasks | NoteCreate | Note added |
| TaskCreate | Task created | |
| TaskComplete | Task marked done |
One Edge Function that routes ALL GHL webhook events to the right handler.
// ghl-event-router const handlers: Record<string, Function> = { 'InboundMessage': handleMessage, 'ContactCreate': handleNewLead, 'AppointmentStatus': handleAppointment, 'OpportunityStageUpdate': handlePipeline, }; Deno.serve(async (req) => { const body = await req.json(); const eventType = body.type; // Enqueue to pgmq, return 200 immediately await supabase.rpc('pgmq_send', { queue: eventType, message: body }); return new Response('OK', { status: 200 }); });
The 4-Layer Stack for GHL API Projects
| Method | Endpoint | Description |
|---|---|---|
| POST | /contacts/ | Create Contact |
| GET | /contacts/:id | Get Contact |
| PUT | /contacts/:id | Update Contact |
| DELETE | /contacts/:id | Delete Contact |
| POST | /contacts/upsert | Create or Update |
| POST | /contacts/search | Search (recommended) |
| GET | /contacts/search/duplicate | Check Duplicates |
| POST | /contacts/:id/tags | Add Tags |
| DELETE | /contacts/:id/tags | Remove Tags |
| POST | /contacts/:id/notes | Create Note |
| POST | /contacts/:id/workflow/:wfId | Add to Workflow |
| POST | /contacts/:id/campaigns/:campId | Add to Campaign |
| Method | Endpoint | Description |
|---|---|---|
| POST | /conversations/messages | Send Message (SMS, Email, WhatsApp) |
| POST | /conversations/messages/inbound | Log Inbound Message |
| GET | /conversations/:id/messages | Get Messages |
| GET | /conversations/search | Search Conversations |
| POST | /conversations/ | Create Conversation |
/conversations/messages endpoint can send SMS, Email, and WhatsApp messages. Set the type field to "SMS", "Email", or "WhatsApp" in the request body.
| Method | Endpoint | Description |
|---|---|---|
| GET | /calendars/ | List Calendars |
| GET | /calendars/:id/free-slots | Check Availability |
| POST | /calendars/services/bookings | Book Appointment |
| Method | Endpoint | Description |
|---|---|---|
| POST | /opportunities/ | Create Opportunity |
| PUT | /opportunities/:id | Update Stage/Value |
| PUT | /opportunities/:id/status | Update Status |
| GET | /opportunities/pipelines | List Pipelines |
| POST | /opportunities/upsert | Create or Update |
| Method | Endpoint | Description |
|---|---|---|
| GET | /locations/search | Search Sub-Accounts |
| POST | /locations/ | Create Sub-Account (Agency Pro only) |
| GET | /locations/:id/customFields | Get Custom Fields |
| POST | /locations/:id/customFields | Create Custom Field |
| Method | Endpoint | Description |
|---|---|---|
| GET | /users/ | Get Users by Location |
| POST | /users/ | Create User |
| PUT | /users/:id | Update User |
| Method | Endpoint | Description |
|---|---|---|
| POST | /oauth/token | Get/Refresh Token |
| POST | /oauth/locationToken | Get Location Token |
| GET | /oauth/installedLocations | List Installed Locations |
| Method | Endpoint | Description |
|---|---|---|
| POST | /associations/relations | Create Relation |
| GET | /associations/relations/:recordId | Get Relations |
authorization_codeaccess_token + refresh_token via POST /oauth/tokenrefresh_token to get a new access token| Category | Method | Endpoint | Description |
|---|---|---|---|
| Workflows | GET | /workflows/ | List Workflows |
| Campaigns | GET | /campaigns/ | List Campaigns |
| Funnels | GET | /funnels/funnel/list | List Funnels |
| Products | GET | /products/list | List Products |
| Blogs | GET | /blogs/list | List Blog Posts |
| Blogs | POST | /blogs/post | Create Blog Post |
| Surveys | GET | /surveys | List Surveys |
| Snapshots | GET | /snapshots | List Snapshots |
| Brand Boards | GET | /brand-boards | Get Brand Boards |
| Scope | Access | Description |
|---|---|---|
contacts.readonly | Read | View contacts & search |
contacts.write | Write | Create, update, delete contacts |
conversations.readonly | Read | View conversations |
conversations.write | Write | Create conversations |
conversations/message.readonly | Read | View messages |
conversations/message.write | Write | Send messages |
calendars.readonly | Read | View calendars & slots |
calendars.write | Write | Book & manage appointments |
opportunities.readonly | Read | View pipeline & deals |
opportunities.write | Write | Create & update deals |
locations.readonly | Read | View sub-accounts |
locations.write | Write | Create sub-accounts |
users.readonly | Read | View users |
users.write | Write | Create & manage users |
workflows.readonly | Read | View workflows |
| Error | Cause | Fix |
|---|---|---|
| 401 Unauthorized | Token expired or invalid | Refresh token via POST /oauth/token |
| 422 Unprocessable | Missing required fields | Check request body — locationId is often missing |
| 400 Bad Request | Missing or wrong Version header | Add correct Version header (see page 19) |
| 429 Rate Limited | Too many requests | Add delays; max ~100 req/min per location |
| 404 Not Found | Wrong ID or endpoint path | Verify resource IDs exist; check endpoint URL |
| 403 Scope Error | Missing permission | Add required scope in app settings |
Authorization header present? (Bearer YOUR_TOKEN)Version header correct for this endpoint?Content-Type: application/json set for POST/PUT?locationId included in the body?2^attempt seconds before retry (1s, 2s, 4s, 8s...)refresh_token — it doesn't expireupsert over create → avoids duplicates| Resource | URL |
|---|---|
| GHL API Playground | buildaischool.com/gohighlevel-api |
| Official API Docs | marketplace.gohighlevel.com/docs/ |
| BuildAI School | buildaischool.com |
| SmartChat | smartchat.ph |
Subscribe to the BuildAI School newsletter for weekly API recipes, new endpoint discoveries, and production patterns from real client projects.