RocketLauncher AI

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

MethodPathScopesRate limit
GET/calendars/calendars.readonlySee GHL API docs
GET/calendars/{calendarId}calendars.readonlySee GHL API docs
GET/calendars/{calendarId}/free-slotscalendars.readonlySee GHL API docs
POST/calendars/events/appointmentscalendars.writeSee GHL API docs
PUT/calendars/events/appointments/{eventId}calendars.writeSee GHL API docs
DELETE/calendars/events/{eventId}calendars.writeSee 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
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"
Node.js (fetch)
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);
}
Python (requests)
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)
Response (200 OK)
{
  "_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
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"
  }'
Node.js (fetch)
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);
Python (requests)
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 (reschedule)
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 (cancel)
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

MCP tool definition (JSON)
{
  "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.

Related reading

Contacts create and updateCreate the contact before booking their appointment.Build a voice AI appointment booking agentFull walkthrough for a GHL voice AI booking agent.Zapier, Make, n8n vs direct APIWhen to use a no-code connector vs writing API calls directly.GoHighLevel API overviewAll API and webhook recipes.