HubSpot + Aircall: почему нативная интеграция теряет контекст звонков
HubSpot + Aircall - распространённая связка в B2B-командах. Нативная интеграция включается одним кликом в Aircall Marketplace. Но есть архитектурная проблема: все звонки логируются на Contact, а не на Deal. Это критично для команд, где сделки ведут несколько менеджеров или где один контакт участвует в нескольких параллельных сделках.
Что происходит при нативной интеграции
Нативная интеграция Aircall -> HubSpot работает так:
- Звонок завершён -> Aircall находит контакт в HubSpot по номеру телефона
- Создаёт Call Engagement на найденном Contact
- Запись разговора крепится к Contact Activity
Результат: на странице Deal -> Activity нет ни одного звонка. Все звонки скрыты в Contact Timeline. Чтобы увидеть историю переговоров по сделке, менеджер должен открыть карточку Contact и вручную просматривать Activity - смешанную со звонками из других сделок.
Три конкретные проблемы
Проблема 1 - неверная привязка при множественных сделках. Контакт участвует в двух сделках: одна активная, одна won. Звонок по активной сделке попадает в Contact Timeline без привязки к конкретной Deal. Анализ звонков по сделке становится невозможным.
Проблема 2 - запись разговора недоступна в контексте Deal. Руководитель проверяет Deal перед созвоном с клиентом - нет истории звонков. Нужно переходить в Contact, листать хронологию, находить нужный звонок. Это 3-5 минут дополнительного времени на каждый созвон.
Проблема 3 - SDR передаёт сделку AE, история обрывается. После передачи Deal новому менеджеру контекст SDR-звонков виден только в Contact Timeline старого менеджера. AE не имеет полной картины переговоров на странице Deal.
Правильное решение: Aircall webhook + Engagements API
Отключаем нативную интеграцию. Строим собственную на основе Aircall webhook и HubSpot Engagements API v3.
Шаг 1 - получение Call через Aircall webhook:
from flask import Flask, request, abort
import requests, hashlib, hmac
app = Flask(__name__)
AIRCALL_API_TOKEN = "your_aircall_api_token" # Basic Auth: api_id:api_token
HUBSPOT_TOKEN = "your_hubspot_private_app_token"
HS_BASE = "https://api.hubapi.com"
@app.route("/aircall/webhook", methods=["POST"])
def aircall_webhook():
# Aircall верифицирует через Basic Auth на стороне вашего сервера
# Или через Aircall Webhook Secret (HMAC-SHA256)
data = request.json
event = data.get("event", "")
if event != "call.ended":
return "ok", 200
call = data.get("data", {})
process_aircall_call(call)
return "ok", 200
Шаг 2 - найти Deal в HubSpot по номеру телефона контакта:
def find_hubspot_deal_by_phone(phone: str) -> dict | None:
"""Find active deal associated with contact having this phone number."""
hs = requests.Session()
hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})
# Найти контакт по телефону
r = hs.post(f"{HS_BASE}/crm/v3/objects/contacts/search", json={
"filterGroups": [{"filters": [
{"propertyName": "phone", "operator": "EQ", "value": phone}
]}],
"properties": ["firstname", "lastname", "phone"],
"limit": 1,
})
contacts = r.json().get("results", [])
if not contacts:
return None
contact_id = contacts[0]["id"]
# Найти активные сделки контакта
r2 = hs.get(f"{HS_BASE}/crm/v4/objects/contacts/{contact_id}/associations/deals")
deal_ids = [a["toObjectId"] for a in r2.json().get("results", [])]
if not deal_ids:
return None
# Взять последнюю активную сделку (не closed won/lost)
for deal_id in deal_ids:
r3 = hs.get(f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
params={"properties": "dealstage,dealname,closedate"})
deal = r3.json()
stage = deal.get("properties", {}).get("dealstage", "")
if stage not in ("closedwon", "closedlost"):
return {"id": deal_id, "contact_id": contact_id, **deal}
return None
Шаг 3 - создать Call Engagement на Deal через Engagements API v3:
def create_call_on_deal(deal_id: str, contact_id: str, call: dict):
"""Create Call engagement associated with Deal (not just Contact)."""
hs = requests.Session()
hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})
duration_ms = call.get("duration", 0) * 1000
recording_url = call.get("recording", "")
direction = "INBOUND" if call.get("direction") == "inbound" else "OUTBOUND"
phone = call.get("from", {}).get("phone_number", "")
# Создать объект Call в HubSpot
payload = {
"properties": {
"hs_call_direction": direction,
"hs_call_duration": str(duration_ms),
"hs_call_from_number": phone,
"hs_call_recording_url": recording_url,
"hs_call_status": "COMPLETED",
"hs_call_title": f"Aircall: {call.get('from', {}).get('name', '')}",
"hs_call_body": call.get("comments", ""),
"hs_timestamp": str(call.get("started_at", 0) * 1000),
}
}
r = hs.post(f"{HS_BASE}/crm/v3/objects/calls", json=payload)
r.raise_for_status()
call_id = r.json()["id"]
# Привязать звонок к Deal
hs.put(
f"{HS_BASE}/crm/v4/objects/calls/{call_id}/associations/deals/{deal_id}",
json=[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 27}],
)
# Привязать к Contact тоже (для полной картины)
hs.put(
f"{HS_BASE}/crm/v4/objects/calls/{call_id}/associations/contacts/{contact_id}",
json=[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 194}],
)
return call_id
def process_aircall_call(call: dict):
phone = call.get("to", {}).get("phone_number", "") or call.get("from", {}).get("phone_number", "")
deal = find_hubspot_deal_by_phone(phone)
if not deal:
return # нет активной сделки - логируем только на Contact стандартным путём
create_call_on_deal(deal["id"], deal["contact_id"], call)
associationTypeId: 27 - стандартный тип для Call -> Deal в HubSpot. 194 - для Call -> Contact.
Добавление AI-транскрипта Aircall
Aircall Intelligence (дополнительный модуль) генерирует транскрипт звонка. Он доступен через GET /v1/calls/{id} через 5-10 минут после завершения звонка.
import time
def get_aircall_transcript(call_id: str) -> str | None:
"""Fetch AI transcript from Aircall. Available ~10 min after call ends."""
import base64
auth = base64.b64encode(f"{AIRCALL_API_ID}:{AIRCALL_API_TOKEN}".encode()).decode()
headers = {"Authorization": f"Basic {auth}"}
for attempt in range(6): # poll up to 5 minutes
r = requests.get(f"https://api.aircall.io/v1/calls/{call_id}", headers=headers)
call_data = r.json().get("call", {})
transcript = call_data.get("transcription", {}).get("transcript", "")
if transcript:
return transcript
time.sleep(50) # wait 50 seconds between attempts
return None
Транскрипт добавляется в hs_call_body при обновлении Call engagement через PATCH /crm/v3/objects/calls/{id}.
Реальный кейс
B2B SaaS-компания с командой из 8 менеджеров и 500+ звонков в месяц. После включения нативной интеграции обнаружили: на странице сделки - нет ни одного звонка. Все 500 звонков за месяц видны только в Contact Timeline.
После кастомной интеграции:
- Каждый звонок привязан к конкретной Deal
- История звонков доступна прямо на странице сделки
- AI-транскрипты обновляются через 10 минут после звонка
- 0 потерянного контекста при передаче сделки между менеджерами
Для кого это актуально
Все команды на HubSpot + Aircall с более чем одной активной сделкой на контакт. Если у вас 1 сделка на 1 контакт и нет передачи между менеджерами - нативная интеграция приемлема.
Та же проблема существует для HubSpot + Gong и HubSpot + Salesloft.
Часто задаваемые вопросы
Работает ли это с Aircall на HubSpot Enterprise?
Да. HubSpot Enterprise открывает доступ к дополнительным типам associations и custom engagement types, но базовая логика идентична. Engagements API v3 работает на всех тарифах включая Starter.
Как обработать звонки когда контакт не найден в HubSpot?
Если find_hubspot_deal_by_phone возвращает None - создать Contact в HubSpot автоматически через POST /crm/v3/objects/contacts и логировать звонок на Contact без Deal. Или отправить уведомление менеджеру на Slack/email для ручной обработки.
Можно ли одновременно использовать нативную интеграцию и кастомную?
Нет - получите дубли. Нативную интеграцию нужно отключить в Aircall Dashboard -> Integrations -> HubSpot -> Disconnect. Только потом включать кастомную через webhook.
Как передавать звонки если у контакта несколько активных сделок?
В нашем примере берётся первая активная сделка. Более точное решение: добавить в Aircall Custom Field или Tags поле с Deal ID. Перед звонком менеджер выбирает сделку, это поле передаётся в webhook и устраняет неоднозначность.
Итог
Нативная интеграция HubSpot + Aircall создаёт структурный разрыв: звонки на Contact, контекст на Deal. Решение:
- Отключить нативную интеграцию
- Aircall webhook
call.ended-> поиск активной Deal по телефону - Создать Call engagement через
POST /crm/v3/objects/calls - Привязать к Deal через Associations API v4 (
associationTypeId: 27) - AI-транскрипт добавить через PATCH через ~10 минут
Если работаете с HubSpot + Aircall и нужна корректная привязка звонков к сделкам - опишите задачу команде Exceltic.dev.