API and Recipes
Sync GoHighLevel to Google Sheets and a Data Warehouse
By Marnix Geerkens. Published 2026-05-28. Updated 2026-05-28.
In short
GoHighLevel does not have a native bulk export to Google Sheets or a data warehouse. You pull data via the REST API using paginated GET requests, then write it to Sheets using the Google Sheets API or to a database using any SQL client. Run this on a schedule (every hour, every night) for a reporting layer that your team can query without touching GHL.
- Pull contacts with GET /contacts/search, paginated by startAfter cursor.
- Pull opportunities with GET /opportunities/search, paginated the same way.
- Write rows to Google Sheets with the Sheets API v4 spreadsheets.values.append method.
- For a true data warehouse (BigQuery, Postgres, Snowflake), write to tables with an UPSERT on the GHL contact or opportunity ID.
Endpoints
| Method | Path | Scopes | Rate limit |
|---|---|---|---|
| GET | /contacts/search | contacts.readonly | See GHL API docs |
| GET | /opportunities/search | opportunities.readonly | See GHL API docs |
| GET | /calendars/events/appointments | calendars.readonly | See GHL API docs |
Authentication
Use a private integration token for the GHL API calls. This is a long-lived token scoped to a single sub-account.
For Google Sheets, you need a Google service account with the Sheets API enabled and editor access to the spreadsheet. Download the service account JSON key and store it as an environment variable or a secrets manager secret.
Never commit the Google service account key to your repo. Add it to your .gitignore and load it from an environment variable or a secrets manager.
For a data warehouse (BigQuery, Postgres, Snowflake), use the appropriate client library and store the connection credentials in environment variables.
Recipe 1. Paginate through all GHL contacts
The contacts search endpoint returns up to 100 records at a time. Use the startAfter cursor from the response to get the next page. Keep fetching until you get fewer records than the limit.
This pattern works the same way for opportunities and appointments. Always handle the case where a page returns zero records to avoid infinite loops.
async function* getAllContacts(token, locationId) {
const base = 'https://services.leadconnectorhq.com';
const headers = {
Authorization: `Bearer ${token}`,
Version: '2021-07-28',
};
const limit = 100;
let startAfter = null;
while (true) {
const params = new URLSearchParams({ locationId, limit: String(limit) });
if (startAfter) params.append('startAfter', startAfter);
const res = await fetch(`${base}/contacts/search?${params}`, { headers });
if (!res.ok) throw new Error(`GHL error: ${res.status}`);
const data = await res.json();
const contacts = data.contacts ?? [];
for (const contact of contacts) {
yield contact;
}
if (contacts.length < limit) break; // last page
startAfter = data.meta?.startAfterRecord ?? contacts[contacts.length - 1].id;
}
}
// Usage
for await (const contact of getAllContacts(process.env.GHL_TOKEN, process.env.GHL_LOCATION_ID)) {
console.log(contact.id, contact.email);
}import time
import requests
def get_all_contacts(token, location_id):
base = "https://services.leadconnectorhq.com"
headers = {"Authorization": f"Bearer {token}", "Version": "2021-07-28"}
limit = 100
start_after = None
while True:
params = {"locationId": location_id, "limit": limit}
if start_after:
params["startAfter"] = start_after
res = requests.get(f"{base}/contacts/search", headers=headers, params=params)
res.raise_for_status()
data = res.json()
contacts = data.get("contacts", [])
yield from contacts
if len(contacts) < limit:
break # last page
meta = data.get("meta", {})
start_after = meta.get("startAfterRecord") or contacts[-1]["id"]
time.sleep(0.2) # be kind to the rate limitRecipe 2. Append contacts to a Google Sheet
Use the Google Sheets API v4 to append rows. The spreadsheets.values.append method adds rows at the end of existing data, which is ideal for an incremental sync.
For a full refresh (replace all rows), clear the sheet first with spreadsheets.values.clear, then write a header row, then append all contacts. For incremental sync, track the last sync timestamp and only pull contacts updated since then.
import { google } from 'googleapis';
async function appendContactsToSheet(contacts, spreadsheetId, sheetName) {
const auth = new google.auth.GoogleAuth({
keyFile: process.env.GOOGLE_APPLICATION_CREDENTIALS,
// or: credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON)
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
const sheets = google.sheets({ version: 'v4', auth });
const rows = contacts.map((c) => [
c.id,
c.email ?? '',
c.firstName ?? '',
c.lastName ?? '',
c.phone ?? '',
(c.tags ?? []).join(', '),
c.dateAdded ?? '',
c.dateUpdated ?? '',
]);
await sheets.spreadsheets.values.append({
spreadsheetId,
range: `${sheetName}!A1`,
valueInputOption: 'RAW',
insertDataOption: 'INSERT_ROWS',
requestBody: { values: rows },
});
console.log(`Appended ${rows.length} contacts`);
}import json
import os
import gspread
from google.oauth2.service_account import Credentials
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
creds = Credentials.from_service_account_info(
json.loads(os.environ["GOOGLE_SERVICE_ACCOUNT_JSON"]),
scopes=SCOPES,
)
gc = gspread.authorize(creds)
def append_contacts_to_sheet(contacts, spreadsheet_id, sheet_name):
sh = gc.open_by_key(spreadsheet_id)
ws = sh.worksheet(sheet_name)
rows = [
[
c.get("id", ""),
c.get("email", ""),
c.get("firstName", ""),
c.get("lastName", ""),
c.get("phone", ""),
", ".join(c.get("tags", [])),
c.get("dateAdded", ""),
c.get("dateUpdated", ""),
]
for c in contacts
]
if rows:
ws.append_rows(rows, value_input_option="RAW")
print(f"Appended {len(rows)} contacts")Recipe 3. Upsert contacts into PostgreSQL for a real data warehouse
For a proper data warehouse, use a PostgreSQL INSERT ... ON CONFLICT DO UPDATE (upsert) keyed on the GHL contact ID. This is safe to run repeatedly without creating duplicates.
Add a synced_at column to track when each row was last written. Index the dateUpdated column in GHL to support incremental queries (pull only contacts updated since your last sync run).
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL);
async function upsertContacts(contacts) {
const rows = contacts.map((c) => ({
id: c.id,
location_id: c.locationId,
email: c.email ?? null,
first_name: c.firstName ?? null,
last_name: c.lastName ?? null,
phone: c.phone ?? null,
tags: c.tags ?? [],
date_added: c.dateAdded ? new Date(c.dateAdded) : null,
date_updated: c.dateUpdated ? new Date(c.dateUpdated) : null,
synced_at: new Date(),
}));
await sql`
INSERT INTO ghl_contacts ${sql(rows)}
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
phone = EXCLUDED.phone,
tags = EXCLUDED.tags,
date_updated = EXCLUDED.date_updated,
synced_at = EXCLUDED.synced_at
`;
console.log(`Upserted ${rows.length} contacts`);
}CREATE TABLE IF NOT EXISTS ghl_contacts (
id TEXT PRIMARY KEY,
location_id TEXT NOT NULL,
email TEXT,
first_name TEXT,
last_name TEXT,
phone TEXT,
tags TEXT[] DEFAULT '{}',
date_added TIMESTAMPTZ,
date_updated TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ghl_contacts_location
ON ghl_contacts (location_id);
CREATE INDEX IF NOT EXISTS idx_ghl_contacts_email
ON ghl_contacts (email);Common errors and fixes
Infinite loop on pagination: the startAfter cursor did not advance. Make sure you are reading the cursor from the API response, not reusing the same value. Log the cursor on each page during testing.
Google Sheets 429: you are appending too many rows too quickly. Add a delay between batch appends or use a single large append instead of one append per contact.
Duplicate rows in Google Sheets: you appended the same contacts twice without clearing the sheet first. Either use a clear-then-write pattern or track the last sync timestamp and only pull new contacts.
Rate limit from GHL: the pagination loop is running too fast. Add a small delay (100-200ms) between page requests to stay within rate limits.
Copy as an MCP tool
{
"name": "ghl_export_contacts",
"description": "Export all GoHighLevel contacts for a location to a JSON array, paginating automatically.",
"inputSchema": {
"type": "object",
"properties": {
"locationId": { "type": "string" },
"updatedSince": {
"type": "string",
"description": "ISO 8601 datetime. Only return contacts updated after this time."
},
"limit": {
"type": "number",
"description": "Max contacts to return (default 1000, max 10000)"
}
},
"required": ["locationId"]
}
}You need a GoHighLevel account to use the API. Start the 30-day trial through our link.
Frequently asked questions
How do I do an incremental sync instead of a full export?
Store the timestamp of your last sync run. On the next run, pass a dateUpdated filter to the GHL contacts search endpoint so you only pull contacts that changed since the last run. Confirm the exact filter parameter name in the current GHL API docs.
Can I sync GHL data to BigQuery?
Yes. Use the BigQuery Storage Write API or the bigquery Node.js client. The pattern is the same as PostgreSQL: paginate GHL, transform to rows, and insert with a MERGE or INSERT ... ON CONFLICT equivalent.
How often should I run the sync?
For reporting dashboards, every hour is usually enough. For real-time pipelines where speed matters (e.g. triggering ad suppression when a lead converts), use outbound webhooks instead of a polling sync.
Does GHL have a native data export?
GHL has a contact CSV export in the UI, but it is manual and does not cover opportunities or appointments. The API is the only way to automate bulk exports.
