RocketLauncher AI

API and Recipes

GoHighLevel to Airtable Two-Way Sync

By Marnix Geerkens. Published 2026-05-28. Updated 2026-05-28.

In short

A two-way sync between GoHighLevel and Airtable means changes in either system flow to the other. GHL sends changes via outbound webhooks. Airtable sends changes via automations that call your backend. Your backend applies the change to the other system. The biggest risk is an update loop: A updates B, B updates A, repeat forever. Break the loop by comparing a hash or version field before writing.

  • 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

MethodPathScopesRate limit
GET/contacts/{contactId}contacts.readonlySee GHL API docs
PUT/contacts/{contactId}contacts.writeSee GHL API docs
POSThttps://api.airtable.com/v0/{baseId}/{tableId} (Airtable create)Airtable API key / OAuth5 requests/sec per base
PATCHhttps://api.airtable.com/v0/{baseId}/{tableId}/{recordId} (Airtable update)Airtable API key / OAuth5 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.

Recipe 1. 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 });
}

Recipe 2. 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 });
}

Recipe 3. 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 and fixes

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.

Copy as an MCP tool

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"]
  }
}

You need a GoHighLevel account to use the API. Start the 30-day trial through our link.

Frequently asked questions

Why would I sync GHL with Airtable instead of just using GHL?

Airtable is better for flexible views, shared team collaboration without full GHL access, and custom reporting. Many agencies use Airtable as a client-facing dashboard while running all automations in GHL.

Can I use Make or Zapier for this sync instead of writing code?

Yes. Make has both a GHL module and an Airtable module. Build a scenario with a GHL webhook trigger and an Airtable upsert action for the GHL-to-Airtable direction. Reverse it for Airtable-to-GHL. The code approach gives you more control over loop prevention and error handling.

How do I handle conflicts when both systems are updated at the same time?

The simplest rule is "last write wins" based on the timestamp. Compare the dateUpdated field in GHL and the Last Modified time in Airtable. Apply the newer change and discard the older one. For high-stakes fields, log conflicts for manual review.

Does Airtable have a native GHL integration?

Airtable has a Zapier and Make connection but no native GHL integration as of 2026. All direct API syncs require a middleware layer as described in these recipes.

Related reading

Sync GHL to Google Sheets and data warehouseOne-way bulk export patterns for reporting.Contacts create and updateThe GHL API calls powering the sync.Outbound custom webhook with Ed25519 signatureVerify GHL webhooks before processing.GoHighLevel API overviewAll API and webhook recipes.