API and Recipes
GoHighLevel Calendar API: Appointment Booking Recipes
By Marnix Geerkens. Published 2026-05-28. Updated 2026-05-28.
In short
The GoHighLevel Calendar API lets external tools check available time slots and book appointments without touching the GHL dashboard. You need the calendar ID and the contact ID. Use GET /calendars/{calendarId}/free-slots to get open times, then POST /calendars/events/appointments to book. This powers chatbot booking flows, voice AI agents, and external landing page forms.
- Get available slots: GET /calendars/{calendarId}/free-slots with a date range.
- Book an appointment: POST /calendars/events/appointments with slotStartTime, contactId, and calendarId.
- Get all calendar IDs for a location: GET /calendars/?locationId=X.
- The booked appointment triggers any GHL workflow with an "Appointment" trigger automatically.
Endpoints
| Method | Path | Scopes | Rate limit |
|---|---|---|---|
| GET | /calendars/ | calendars.readonly | See GHL API docs |
| GET | /calendars/{calendarId} | calendars.readonly | See GHL API docs |
| GET | /calendars/{calendarId}/free-slots | calendars.readonly | See GHL API docs |
| POST | /calendars/events/appointments | calendars.write | See GHL API docs |
| PUT | /calendars/events/appointments/{eventId} | calendars.write | See GHL API docs |
| DELETE | /calendars/events/{eventId} | calendars.write | See GHL API docs |
Authentication
Use your sub-account Bearer token (private integration token or OAuth access token). Include Version: 2021-07-28 in every request header.
Your token must have the calendars.readonly scope to fetch slots and calendars.write to create appointments.
The calendarId is the unique ID of the specific calendar you want to book into. Fetch it with GET /calendars/?locationId=YOUR_LOCATION_ID. Do not confuse it with the locationId (sub-account ID).
Slot times are returned and accepted in ISO 8601 format in UTC. Convert to the user's local timezone for display, but always send UTC back to the API.
Recipe 1. List available time slots for a calendar
Fetch open slots by passing the calendarId plus a startDate and endDate. The response returns a map of dates to arrays of available time windows. You can filter by timezone for display purposes.
Slots are only returned for dates that have at least one open window within the calendar's working hours and after any existing bookings are subtracted.
curl -G https://services.leadconnectorhq.com/calendars/CALENDAR_ID/free-slots \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Version: 2021-07-28" \
--data-urlencode "startDate=2026-06-01" \
--data-urlencode "endDate=2026-06-07" \
--data-urlencode "timezone=America/New_York"const params = new URLSearchParams({
startDate: '2026-06-01',
endDate: '2026-06-07',
timezone: 'America/New_York',
});
const res = await fetch(
`https://services.leadconnectorhq.com/calendars/CALENDAR_ID/free-slots?${params}`,
{
headers: {
Authorization: 'Bearer YOUR_TOKEN',
Version: '2021-07-28',
},
}
);
const data = await res.json();
// data._dates_ is an object keyed by date string
for (const [date, slots] of Object.entries(data._dates_ ?? {})) {
console.log(date, slots);
}import requests
res = requests.get(
"https://services.leadconnectorhq.com/calendars/CALENDAR_ID/free-slots",
headers={"Authorization": "Bearer YOUR_TOKEN", "Version": "2021-07-28"},
params={
"startDate": "2026-06-01",
"endDate": "2026-06-07",
"timezone": "America/New_York",
},
)
res.raise_for_status()
slots_by_date = res.json().get("_dates_", {})
for date, slots in slots_by_date.items():
print(date, slots){
"_dates_": {
"2026-06-02": [
{ "startTime": "2026-06-02T14:00:00+00:00", "endTime": "2026-06-02T14:30:00+00:00" },
{ "startTime": "2026-06-02T15:00:00+00:00", "endTime": "2026-06-02T15:30:00+00:00" }
],
"2026-06-03": [
{ "startTime": "2026-06-03T13:00:00+00:00", "endTime": "2026-06-03T13:30:00+00:00" }
]
}
}Recipe 2. Book an appointment
Once the user picks a slot, POST to /calendars/events/appointments. You need the calendarId, the contactId (the person being booked), and the slotStartTime from the free-slots response.
The appointmentStatus field defaults to "confirmed". Set it to "new" if you want the appointment to show as pending until the contact confirms via the GHL reminder workflow.
curl -X POST https://services.leadconnectorhq.com/calendars/events/appointments \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Version: 2021-07-28" \
-d '{
"calendarId": "CALENDAR_ID",
"locationId": "YOUR_LOCATION_ID",
"contactId": "CONTACT_ID",
"startTime": "2026-06-02T14:00:00+00:00",
"endTime": "2026-06-02T14:30:00+00:00",
"title": "Discovery Call",
"appointmentStatus": "confirmed",
"assignedUserId": "USER_ID"
}'const res = await fetch(
'https://services.leadconnectorhq.com/calendars/events/appointments',
{
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_TOKEN',
'Content-Type': 'application/json',
Version: '2021-07-28',
},
body: JSON.stringify({
calendarId: 'CALENDAR_ID',
locationId: 'YOUR_LOCATION_ID',
contactId: 'CONTACT_ID',
startTime: '2026-06-02T14:00:00+00:00',
endTime: '2026-06-02T14:30:00+00:00',
title: 'Discovery Call',
appointmentStatus: 'confirmed',
}),
}
);
const { appointment } = await res.json();
console.log('Booked:', appointment.id);import requests
res = requests.post(
"https://services.leadconnectorhq.com/calendars/events/appointments",
headers={
"Authorization": "Bearer YOUR_TOKEN",
"Content-Type": "application/json",
"Version": "2021-07-28",
},
json={
"calendarId": "CALENDAR_ID",
"locationId": "YOUR_LOCATION_ID",
"contactId": "CONTACT_ID",
"startTime": "2026-06-02T14:00:00+00:00",
"endTime": "2026-06-02T14:30:00+00:00",
"title": "Discovery Call",
"appointmentStatus": "confirmed",
},
)
res.raise_for_status()
print(res.json()["appointment"]["id"])Recipe 3. Reschedule or cancel an appointment
To reschedule, PUT /calendars/events/appointments/{eventId} with the new startTime and endTime. To cancel, DELETE /calendars/events/{eventId}. Both actions trigger any GHL workflows listening for appointment status changes.
curl -X PUT https://services.leadconnectorhq.com/calendars/events/appointments/EVENT_ID \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Version: 2021-07-28" \
-d '{
"startTime": "2026-06-03T13:00:00+00:00",
"endTime": "2026-06-03T13:30:00+00:00",
"appointmentStatus": "confirmed"
}'curl -X DELETE https://services.leadconnectorhq.com/calendars/events/EVENT_ID \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Version: 2021-07-28"Common errors and fixes
400 Slot not available: the slot you requested is no longer free. Another booking took it between your free-slots call and your POST. Always handle this in your UI and offer alternatives.
404 Calendar not found: the calendarId does not belong to the location in your token. Confirm the IDs with GET /calendars/?locationId=X.
422 Missing required field: startTime, endTime, contactId, calendarId, and locationId are all required on POST. Double-check the ISO 8601 format for times.
409 Conflict: the contact already has an appointment in this calendar at this time. Check before booking or catch the error and handle it gracefully.
Copy as an MCP tool
{
"name": "ghl_book_appointment",
"description": "Book an appointment in a GoHighLevel calendar for an existing contact.",
"inputSchema": {
"type": "object",
"properties": {
"calendarId": { "type": "string" },
"contactId": { "type": "string" },
"locationId": { "type": "string" },
"startTime": { "type": "string", "description": "ISO 8601 UTC datetime" },
"endTime": { "type": "string", "description": "ISO 8601 UTC datetime" },
"title": { "type": "string" }
},
"required": ["calendarId", "contactId", "locationId", "startTime", "endTime"]
}
}You need a GoHighLevel account to use the API. Start the 30-day trial through our link.
Frequently asked questions
How do I find my calendar ID?
Call GET /calendars/?locationId=YOUR_LOCATION_ID. The response lists all calendars in the sub-account with their IDs and names. Store the IDs you need in your app config.
Does the API book into round-robin or group calendars?
The API books into the specific calendar you pass as calendarId. For round-robin calendars, GHL assigns the team member automatically based on your round-robin settings. Confirm the current behavior in the official GHL API docs, as calendar types and routing logic can change.
Can a voice AI agent use this to book appointments mid-call?
Yes. Fetch available slots during the call, confirm the time with the caller, then POST the appointment before hanging up. The GHL Voice AI agent studio has a native booking action, but the REST API approach works with any voice provider, including Vapi and Retell.
What timezone should I use for startTime?
Send UTC (ISO 8601 with +00:00 or Z). GHL stores times in UTC and converts them for display based on the sub-account timezone and the contact's timezone. Convert locally for display, but always POST UTC.
