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
- Deal won -> create a record in the Airtable “Client Onboarding” base
- Record status updated in Airtable -> Note in Kommo about onboarding progress
- 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.