HubSpot + Aircall: почему нативная интеграция теряет контекст звонков

HubSpot + Aircall: почему нативная интеграция теряет контекст звонков

HubSpot + Aircall - распространённая связка в B2B-командах. Нативная интеграция включается одним кликом в Aircall Marketplace. Но есть архитектурная проблема: все звонки логируются на Contact, а не на Deal. Это критично для команд, где сделки ведут несколько менеджеров или где один контакт участвует в нескольких параллельных сделках.

Что происходит при нативной интеграции

Нативная интеграция Aircall -> HubSpot работает так:

  1. Звонок завершён -> Aircall находит контакт в HubSpot по номеру телефона
  2. Создаёт Call Engagement на найденном Contact
  3. Запись разговора крепится к 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.

Ещё статьи

Все →