Kommo + Airtable: client database and projects from won deals

Airtable is a hybrid tool between a spreadsheet and a database. It’s used as a CRM extension, operations database, and project tracker. There is no native integration with Kommo. We build via Airtable REST API v0 with a Personal Access Token.

What we’re building

  1. Deal won -> create a record in the Airtable “Client Onboarding” base
  2. Record status updated in Airtable -> Note in Kommo about onboarding progress
  3. Sync of the “Next Contact Date” field between Airtable and Kommo

Authentication: Personal Access Token

Airtable has moved from API Key to Personal Access Token (PAT). The API Key is deprecated and will be disabled.

import requests

AIRTABLE_PAT = "pat_XXXXXXX"  # Personal Access Token from Airtable Account Settings
BASE_ID   = "appXXXXXXXXXXXX"  # base ID from URL or via /v0/meta/bases
TABLE_NAME = "Client Onboarding"  # or Table ID: tblXXXXXXXXX

at_session = requests.Session()
at_session.headers.update({
    "Authorization": f"Bearer {AIRTABLE_PAT}",
    "Content-Type": "application/json",
})

AT_BASE = f"https://api.airtable.com/v0/{BASE_ID}"

PAT has granular scopes: data.records:read, data.records:write, schema.bases:read. Minimum for integration: data.records:read + data.records:write.

Creating a record on deal won

def create_airtable_record(deal: dict, contact: dict) -> str:
    """Create Airtable record from Kommo deal. Returns record ID."""
    payload = {
        "fields": {
            "Client Name":   contact.get("name", ""),
            "Company":       contact.get("company", ""),
            "Email":         contact.get("email", ""),
            "Deal Value":    deal.get("price", 0),
            "Kommo Deal ID": str(deal["id"]),       # string! Airtable has no int64
            "Deal Name":     deal.get("name", ""),
            "Won Date":      deal.get("closed_at", "")[:10] if deal.get("closed_at") else "",
            "Status":        "Not Started",          # Airtable Single Select - must match exactly
        }
    }
    r = at_session.post(f"{AT_BASE}/{TABLE_NAME}", json=payload)
    r.raise_for_status()
    return r.json()["id"]  # recXXXXXXXX

Field names in Airtable are case-sensitive and must match the base exactly. Field types also matter: Single Select only accepts values from a preset list - passing an unknown value returns error 422.

Kommo webhook -> record creation:

from flask import Flask, request

app = Flask(__name__)

@app.route("/kommo/webhook", methods=["POST"])
def kommo_webhook():
    data = request.json
    for lead in data.get("leads", {}).get("update", []):
        if lead.get("status_id") == WON_STATUS_ID:
            deal    = get_kommo_deal(lead["id"])
            contact = get_kommo_deal_contact(lead["id"])
            rec_id  = create_airtable_record(deal, contact)
            # Save mapping deal_id -> airtable_record_id for reverse sync
            save_mapping(lead["id"], rec_id)
    return "ok", 200

Airtable Webhook: cursor-based, not push

This is the key difference between Airtable and most platforms. Airtable webhook is not push: Airtable notifies you that changes exist but does not send the actual data. You must request the changes yourself via a cursor.

Creating a webhook:

def create_airtable_webhook(notification_url: str) -> dict:
    """Register Airtable webhook. Returns webhook config with cursor."""
    payload = {
        "notificationUrl": notification_url,
        "specification": {
            "options": {
                "filters": {
                    "fromSources": ["client", "publicApi"],
                    "dataTypes": ["tableData"],
                    "recordChangeScope": TABLE_ID,
                },
                "includes": {
                    "includeCellValuesInFieldIds": ["Status", "Next Contact Date"],
                }
            }
        }
    }
    r = at_session.post(
        f"https://api.airtable.com/v0/bases/{BASE_ID}/webhooks",
        json=payload,
    )
    r.raise_for_status()
    return r.json()

Handling a notification (cursor polling):

import redis  # store cursor between requests

WEBHOOK_ID = "ach_XXXXXXXXX"  # from create_airtable_webhook response
redis_client = redis.Redis()

@app.route("/airtable/notification", methods=["POST"])
def airtable_notification():
    """Airtable sends notification without payload. Must poll for changes."""
    cursor = redis_client.get(f"airtable_cursor_{WEBHOOK_ID}")
    cursor_param = {"cursor": int(cursor)} if cursor else {}

    r = at_session.get(
        f"https://api.airtable.com/v0/bases/{BASE_ID}/webhooks/{WEBHOOK_ID}/payloads",
        params=cursor_param,
    )
    r.raise_for_status()
    response = r.json()

    for payload in response.get("payloads", []):
        changed_records = payload.get("changedFieldsByRecord", {})
        for record_id, changes in changed_records.items():
            if "Status" in changes:
                new_status = changes["Status"]["current"]["value"]
                deal_id = get_deal_id_by_record(record_id)
                if deal_id:
                    add_kommo_note(deal_id, f"Airtable: status changed to '{new_status}'")

    # Save new cursor
    new_cursor = response.get("cursor")
    if new_cursor:
        redis_client.set(f"airtable_cursor_{WEBHOOK_ID}", new_cursor)

    return "ok", 200

Airtable webhooks expire after 7 days. You need to refresh them via POST /bases/{id}/webhooks/{wh_id}/refresh.

Reading and updating records

def get_record(record_id: str) -> dict:
    r = at_session.get(f"{AT_BASE}/{TABLE_NAME}/{record_id}")
    r.raise_for_status()
    return r.json()["fields"]

def update_record(record_id: str, fields: dict) -> dict:
    """PATCH update - only specified fields changed."""
    r = at_session.patch(
        f"{AT_BASE}/{TABLE_NAME}/{record_id}",
        json={"fields": fields},
    )
    r.raise_for_status()
    return r.json()

# Example: sync next contact date from Kommo
def sync_next_contact_date(deal_id: int, next_contact: str):
    record_id = get_record_id_by_deal(deal_id)
    if record_id:
        update_record(record_id, {"Next Contact Date": next_contact})

Airtable API rate limit: 5 requests per second per base. For bulk sync, add time.sleep(0.2) between requests or batch via the PATCH /v0/{baseId}/{tableId} endpoint (up to 10 records at once).

Real case

A digital agency with 25-30 new clients per month maintained two systems: Kommo as the CRM for sales and Airtable as the operations database for the delivery team. Data was copied manually when a deal was won - a delay of 1-2 hours and errors in 15% of cases (wrong email, incomplete name).

After the integration:

  • Record in Airtable is created automatically when the deal moves to Won (<10 seconds)
  • The delivery team sees up-to-date data without waiting
  • Status updates in Airtable are reflected as Notes in Kommo - sales know about onboarding progress

Savings: ~45 minutes of manual data transfer work per day.

Who this is for

Companies where sales works in a CRM (Kommo) and the operations team uses Airtable as their working tool. Typical scenario: sales, marketing, or HR teams that are accustomed to Airtable as a flexible tracker.

For more specialized project management tools - Kommo + Asana, Kommo + Notion, Kommo + Linear.

Frequently asked questions

Why is the Airtable webhook cursor-based and not push?

Airtable is designed as a database with change versioning. The cursor-based approach means you never miss events when your service is unavailable: you can always request changes starting from the last known cursor. Push webhooks without a cursor risk losing events during downtime.

How do Linked Records work?

In Airtable change payloads, linked records appear as an array of record IDs (["recXXX", "recYYY"]). To get the linked record’s data, a separate request to the table is needed. For Kommo integration it’s more convenient to denormalize data into flat fields (e.g. store the client name as a string rather than a link).

How to refresh an Airtable webhook after 7 days?

Webhooks expire if not refreshed. Add to cron every 6 days: POST /v0/bases/{id}/webhooks/{wh_id}/refresh. If the webhook has expired - create a new one via the same create_airtable_webhook() and update the cursor in Redis.

Can files (attachments) be synced from Kommo to Airtable?

Yes, via the Airtable Attachments field - pass an array {"url": "...", "filename": "..."}. The URL must be publicly accessible. Files from Kommo (Notes attachments) can be proxied through your service with a temporary link.

Summary

Key specifics of the Kommo + Airtable integration:

  • PAT authentication (not API Key - deprecated), granular scopes
  • Field names and Single Select values are case-sensitive and must match exactly
  • Webhook: notification without data, cursor-polling required to retrieve changes
  • Webhook expires after 7 days - a refresh cron job is required

If you have Airtable as your operations base and Kommo as your CRM - describe the task to the Exceltic.dev team. We’ll review the stack and set up two-way sync.

More articles

All →