In short
- GHL to Airtable: receive GHL outbound webhooks, map fields, upsert into Airtable using the record ID stored in a GHL custom field.
- Airtable to GHL: set up an Airtable automation that calls a webhook when a record changes, then update the GHL contact via the API.
- Loop prevention: store a sync_source field ("ghl" or "airtable") and skip writing back to the source system.
- Store the Airtable record ID in a GHL custom field so you can always look up the matching record.
Endpoints
| Method | Path | Scopes | Rate limit |
|---|---|---|---|
| GET | /contacts/{contactId} | contacts.readonly | See GHL API docs |
| PUT | /contacts/{contactId} | contacts.write | See GHL API docs |
| POST | https://api.airtable.com/v0/{baseId}/{tableId} (Airtable create) | Airtable API key / OAuth | 5 requests/sec per base |
| PATCH | https://api.airtable.com/v0/{baseId}/{tableId}/{recordId} (Airtable update) | Airtable API key / OAuth | 5 requests/sec per base |
Authentication
GHL: use a private integration token stored in your environment variables.
Airtable: use a personal access token (PAT) or an OAuth token. Store it in your environment variables. Create the token in Airtable Account > Developer Hub > Personal Access Tokens. Scope it to the specific base you are syncing.
Your backend needs to be publicly accessible to receive both the GHL outbound webhooks and the Airtable automation webhook calls.
Confirm the current Airtable API base URL and authentication header format in the official Airtable API documentation, as these details have changed across Airtable API versions.
Recipes
GHL to Airtable: receive a GHL webhook and upsert into Airtable
Set up a GHL outbound webhook for ContactCreate and ContactUpdate events. Your backend receives the payload, looks up the matching Airtable record by the GHL contact ID, and creates or updates it.
Store the Airtable record ID back in a GHL custom field (e.g. "Airtable Record ID") so you have a bi-directional link. You need this for the Airtable-to-GHL direction.
Node.js: GHL webhook handler
const AIRTABLE_TOKEN = process.env.AIRTABLE_TOKEN;
const AIRTABLE_BASE_ID = process.env.AIRTABLE_BASE_ID;
const AIRTABLE_TABLE = 'Contacts'; // your table name
const GHL_TOKEN = process.env.GHL_TOKEN;
const AIRTABLE_RECORD_FIELD = 'Airtable Record ID'; // GHL custom field name
async function airtableRequest(method, path, body) {
const res = await fetch(`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${encodeURIComponent(AIRTABLE_TABLE)}${path}`, {
method,
headers: {
Authorization: `Bearer ${AIRTABLE_TOKEN}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`Airtable error: ${res.status} ${await res.text()}`);
return res.json();
}
export async function POST(req) {
const payload = await req.json();
// Skip if this event originated from Airtable (loop prevention)
if (payload.source === 'airtable-sync') {
return new Response('skip', { status: 200 });
}
const { contactId, email, firstName, lastName, phone, tags } = payload;
const fields = {
'GHL Contact ID': contactId,
Email: email ?? '',
'First Name': firstName ?? '',
'Last Name': lastName ?? '',
Phone: phone ?? '',
Tags: (tags ?? []).join(', '),
'Last GHL Sync': new Date().toISOString(),
};
// Look up existing Airtable record by GHL contact ID
const search = await airtableRequest(
'GET',
`?filterByFormula=${encodeURIComponent(`{GHL Contact ID}="${contactId}"`)}`
);
let airtableRecordId;
if (search.records.length > 0) {
// Update existing
airtableRecordId = search.records[0].id;
await airtableRequest('PATCH', `/${airtableRecordId}`, { fields });
} else {
// Create new
const created = await airtableRequest('POST', '', { fields });
airtableRecordId = created.id;
// Store the Airtable record ID back in GHL
await fetch(`https://services.leadconnectorhq.com/contacts/${contactId}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${GHL_TOKEN}`,
'Content-Type': 'application/json',
Version: '2021-07-28',
},
body: JSON.stringify({
customFields: [
{ id: process.env.GHL_AIRTABLE_FIELD_ID, value: airtableRecordId },
],
}),
});
}
return new Response('ok', { status: 200 });
}Airtable to GHL: automation that calls your backend on record change
In Airtable, create an automation: Trigger = "When a record is updated" on your Contacts table. Action = "Run a script" or "Send a webhook" (depending on your Airtable plan). Post the changed record fields and the GHL contact ID to your backend.
Your backend receives the Airtable payload and updates the GHL contact. Include a sync_source marker in the GHL update to prevent the GHL webhook from writing back to Airtable.
Airtable automation script (Run a script)
// This runs inside Airtable's scripting environment
// Triggered when a record is updated
const record = input.config().record; // passed from the automation trigger
const ghlContactId = record['GHL Contact ID'];
if (!ghlContactId) {
console.log('No GHL Contact ID, skipping');
return;
}
await fetch('https://yourbackend.com/airtable-to-ghl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ghlContactId,
firstName: record['First Name'],
lastName: record['Last Name'],
email: record['Email'],
phone: record['Phone'],
tags: record['Tags'] ? record['Tags'].split(', ') : [],
source: 'airtable',
}),
});Node.js: Airtable webhook handler (writes to GHL)
export async function POST(req) {
const body = await req.json();
const { ghlContactId, firstName, lastName, email, phone, tags, source } = body;
if (source !== 'airtable') {
return new Response('unexpected source', { status: 400 });
}
const res = await fetch(
`https://services.leadconnectorhq.com/contacts/${ghlContactId}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.GHL_TOKEN}`,
'Content-Type': 'application/json',
Version: '2021-07-28',
},
body: JSON.stringify({
firstName,
lastName,
email,
phone,
tags,
// Tag this update so the GHL outbound webhook ignores it
source: 'airtable-sync',
}),
}
);
if (!res.ok) {
console.error('GHL update failed:', await res.text());
return new Response('error', { status: 500 });
}
return new Response('ok', { status: 200 });
}Loop prevention: comparing hashes to skip no-op updates
The sync_source marker approach works well but depends on GHL sending the marker field in the outbound webhook payload. A more robust approach is to hash the relevant fields and only write when the hash changes.
Store the last-written hash (SHA-256 of the serialized fields you care about) alongside the record in both systems. Before writing, compare hashes and skip if they match.
Node.js: hash-based change detection
import crypto from 'crypto';
function hashFields(fields) {
return crypto
.createHash('sha256')
.update(JSON.stringify(fields, Object.keys(fields).sort()))
.digest('hex');
}
async function syncGhlToAirtable(contact, lastSyncHash) {
const fields = {
email: contact.email,
firstName: contact.firstName,
lastName: contact.lastName,
phone: contact.phone,
tags: (contact.tags ?? []).sort().join(','),
};
const currentHash = hashFields(fields);
if (currentHash === lastSyncHash) {
console.log('No change, skipping', contact.id);
return { skipped: true };
}
// Write to Airtable and store the new hash
await writeToAirtable(contact);
await storeHash(contact.id, currentHash); // your own storage
return { synced: true, hash: currentHash };
}Common errors
- Update loop: GHL updates Airtable, Airtable automation fires, updates GHL, GHL webhook fires, updates Airtable again. Break this by checking the source field or using hash comparison before writing.
- Airtable rate limit (429): the Airtable API allows 5 requests per second per base. Add a delay between batch operations and implement exponential back-off.
- Missing Airtable record ID in GHL: if the GHL custom field was not set when the contact was created, your Airtable-to-GHL sync cannot find the right record. Add a fallback lookup by email.
- GHL custom field not updating: confirm the custom field ID is correct and that the token has contacts.write scope. Custom field IDs are long strings found in GHL Settings > Custom Fields.
MCP tool definition
MCP tool definition (JSON)
{
"name": "sync_contact_to_airtable",
"description": "Sync a GoHighLevel contact to Airtable, creating or updating the record.",
"inputSchema": {
"type": "object",
"properties": {
"contactId": { "type": "string" },
"email": { "type": "string" },
"firstName": { "type": "string" },
"lastName": { "type": "string" },
"phone": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } }
},
"required": ["contactId"]
}
}