HubSpot + Zoom: почему нативная интеграция привязывает демо-звонки не к сделке

HubSpot + Zoom - стандартная связка для B2B-команд, которые проводят discovery-звонки и демо через Zoom. Нативная интеграция устанавливается за несколько минут: подключаете Zoom в HubSpot App Marketplace, и встречи начинают синхронизироваться. Но есть архитектурная проблема: все встречи логируются на Contact, а не на Deal. Для команды, где каждый менеджер ведёт 20-30 сделок параллельно, это означает полную потерю контекста переговоров на уровне сделки.

В проектах на HubSpot мы видим одну и ту же картину: страница Deal пустая - ни одной встречи, хотя по этой сделке прошло пять демо и три технических созвона. Вся история переговоров скрыта в Contact Timeline, перемешана со звонками по другим сделкам и недоступна AE, который принял сделку от SDR.

В этой статье разберём, почему так устроена нативная интеграция, какие конкретные потери это создаёт и как правильно настроить синхронизацию через Zoom webhook + HubSpot Meetings API.

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

Нативная интеграция HubSpot + Zoom работает так:

  1. Встреча в Zoom завершена
  2. HubSpot находит участника по email
  3. Создаёт Meeting Engagement на Contact этого участника
  4. Облачная запись (cloud recording) добавляется как ссылка в Activity встречи

Итог: страница Deal -> Activity пустая. Все встречи видны только в Contact Timeline. Причём если в Zoom-звонке участвует несколько человек с разными HubSpot-контактами, каждая копия встречи привязана к своему контакту отдельно - без единого представления в рамках сделки.

Дополнительная сложность: в январе 2025 года HubSpot мигрировал Zoom-активности в фреймворк marketing events. Contact-based свойства Zoom (Last registered Zoom webinar, Zoom meeting count) перестали работать как критерии для фильтрации контактов и запуска workflow. Команды, которые сегментировали контакты по Zoom-активности, обнаружили поломку без предупреждения.

Три конкретные проблемы

Проблема 1 - история переговоров не видна на Deal. AE открывает карточку сделки перед закрывающим звонком - ни одной встречи. Нужно переходить в Contact, листать смешанную хронологию, искать записи нужных демо. Если контакт участвует в двух параллельных сделках, понять к какой сделке относится каждая встреча невозможно.

Проблема 2 - передача сделки теряет контекст. SDR провёл три discovery-звонка через Zoom, зафиксировал pain points. Сделка передаётся AE. На странице Deal - пусто. SDR должен пересказывать историю устно или вести отдельный документ. Классический разрыв в командных продажах.

Проблема 3 - облачные записи не синхронизируются при отсутствии email. Если участник Zoom не авторизован (внешний участник без email в Zoom), запись не попадает в HubSpot вообще. Локальные записи (local recording) не синхронизируются никогда - только cloud recording. Команды с корпоративной политикой против cloud recording теряют весь контент встреч.

Архитектурная причина

Нативная интеграция использует Contact как якорный объект, потому что это единственное надёжное соответствие: email участника Zoom = Contact в HubSpot. Привязать встречу к Deal нативно невозможно - нет сигнала, который указывал бы на конкретную сделку.

HubSpot официально говорит: “activities for Contacts will always be associated with the 5 most recent open deals automatically”. На практике это означает, что встреча появляется на Deal только если это одна из пяти последних сделок контакта и если она уже присвоена. Для команд с высоким объёмом и длинными циклами сделок это не работает.

Meeting Engagement в терминах HubSpot API - это объект /crm/v3/objects/meetings с явными ассоциациями к Deal и Contact. Нативная интеграция создаёт объект, но не добавляет ассоциацию к Deal.

Правильное решение: Zoom webhook + Meetings API

Отключаем нативную интеграцию Zoom в HubSpot App Marketplace. Строим собственную через Zoom webhook и HubSpot Meetings API.

Шаг 1 - настройка Zoom webhook:

В Zoom Marketplace создаём приложение типа Event Subscription. Подписываемся на события:

  • meeting.ended - встреча завершена
  • recording.completed - облачная запись готова
from flask import Flask, request
import requests, hashlib, hmac, json

app = Flask(__name__)
ZOOM_SECRET_TOKEN = "your_zoom_webhook_secret_token"
HUBSPOT_TOKEN     = "your_hubspot_private_app_token"
HS_BASE           = "https://api.hubapi.com"

@app.route("/zoom/webhook", methods=["POST"])
def zoom_webhook():
    # Zoom webhook verification (HMAC-SHA256)
    ts      = request.headers.get("x-zm-request-timestamp", "")
    sig_hdr = request.headers.get("x-zm-signature", "")
    body    = request.get_data(as_text=True)
    message = f"v0:{ts}:{body}"
    sig     = "v0=" + hmac.new(ZOOM_SECRET_TOKEN.encode(), message.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, sig_hdr):
        return "Unauthorized", 401

    event = request.json
    if event.get("event") == "meeting.ended":
        process_meeting_ended(event["payload"]["object"])
    elif event.get("event") == "recording.completed":
        attach_recording(event["payload"]["object"])
    return "ok", 200

Шаг 2 - найти Deal по email организатора встречи:

def find_deal_for_host(host_email: str) -> dict | None:
    hs = requests.Session()
    hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})

    # Найти контакт по email
    r = hs.get(f"{HS_BASE}/crm/v3/objects/contacts/{host_email}",
               params={"idProperty": "email", "properties": "firstname,lastname"})
    if r.status_code != 200:
        return None
    contact = r.json()
    contact_id = contact["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

    # Взять последнюю открытую сделку
    for deal_id in deal_ids:
        r3 = hs.get(f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
                    params={"properties": "dealstage,dealname"})
        stage = r3.json().get("properties", {}).get("dealstage", "")
        if stage not in ("closedwon", "closedlost"):
            return {"deal_id": deal_id, "contact_id": contact_id}
    return None

Шаг 3 - создать Meeting Engagement на Deal:

def process_meeting_ended(meeting: dict):
    host_email = meeting.get("host_email", "")
    ctx        = find_deal_for_host(host_email)
    if not ctx:
        return  # нет активной сделки у хоста - пропускаем

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

    start_ms = meeting.get("start_time_unix", 0) * 1000
    end_ms   = start_ms + (meeting.get("duration", 0) * 60 * 1000)

    # Собираем участников
    participants = ", ".join([
        p.get("user_email", p.get("name", ""))
        for p in meeting.get("participants", {}).get("registrants", [])
    ])

    payload = {
        "properties": {
            "hs_meeting_title":       meeting.get("topic", "Zoom meeting"),
            "hs_meeting_start_time":  str(start_ms),
            "hs_meeting_end_time":    str(end_ms),
            "hs_meeting_outcome":     "COMPLETED",
            "hs_meeting_location":    f"Zoom: {meeting.get('join_url', '')}",
            "hs_meeting_body":        f"Участники: {participants}\nZoom Meeting ID: {meeting.get('id')}",
        },
        "associations": [
            {
                "to": {"id": ctx["deal_id"]},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 212}]
            },
            {
                "to": {"id": ctx["contact_id"]},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 200}]
            }
        ]
    }

    r = hs.post(f"{HS_BASE}/crm/v3/objects/meetings", json=payload)
    r.raise_for_status()
    meeting_id = r.json()["id"]

    # Сохраняем mapping Zoom Meeting ID -> HubSpot Meeting ID
    # для последующего прикрепления записи
    save_mapping(meeting.get("id"), meeting_id, ctx["deal_id"])
    return meeting_id

associationTypeId: 212 - HUBSPOT_DEFINED тип для Meeting -> Deal. 200 - для Meeting -> Contact. Актуальные типы всегда доступны через GET /crm/v4/associations/meetings/deals/labels.

Шаг 4 - прикрепить облачную запись после готовности:

def attach_recording(recording: dict):
    zoom_meeting_id = recording.get("id")
    hs_meeting_id   = get_mapping(zoom_meeting_id)  # из сохранённого ранее маппинга
    if not hs_meeting_id:
        return

    # Берём первый mp4 или transcript
    recording_url = next(
        (f["download_url"] for f in recording.get("recording_files", [])
         if f.get("file_type") == "MP4"),
        ""
    )
    transcript = next(
        (f["download_url"] for f in recording.get("recording_files", [])
         if f.get("file_type") == "TRANSCRIPT"),
        ""
    )

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

    body_update = f"Запись: {recording_url}"
    if transcript:
        body_update += f"\nТранскрипт: {transcript}"

    hs.patch(f"{HS_BASE}/crm/v3/objects/meetings/{hs_meeting_id}", json={
        "properties": {"hs_meeting_body": body_update}
    })

Реальный кейс

SaaS-компания с командой из 6 AE, 150+ демо в месяц через Zoom. После подключения нативной интеграции обнаружили: страница каждой сделки в HubSpot - без единого демо. История переговоров существует только в Contact Timeline.

После внедрения кастомной интеграции:

  • Каждое демо привязано к конкретной Deal
  • Облачная запись доступна прямо со страницы сделки
  • Передача сделки между SDR и AE - полный контекст сохранён
  • 0 дублей - обработка идемпотентна по Zoom Meeting ID

Время реализации: 2-3 дня. Логика простая, но надёжная: один webhook, один маппинг, один patch при готовности записи.

Для кого актуально

Любая B2B-команда на HubSpot + Zoom с процессом discovery -> демо -> технический созвон -> клоузинг. Если каждый контакт участвует ровно в одной сделке и у вас один менеджер на сделку от начала до конца - нативная интеграция приемлема. Во всех остальных случаях история переговоров будет теряться.

Схожая проблема описана для HubSpot + Aircall и HubSpot + Apollo - это системная особенность нативных интеграций HubSpot, где Contact является точкой входа, а не Deal.

Часто задаваемые вопросы

Работает ли это с Zoom Phone, или только с Zoom Meetings?

Zoom Meetings и Zoom Phone - разные продукты с разными webhook-событиями. Zoom Phone имеет собственную нативную интеграцию с HubSpot (Using the Zoom Phone for HubSpot integration), которая логирует звонки отдельно от встреч. Описанное решение работает для Zoom Meetings (демо, discovery, созвоны). Для Zoom Phone нужна отдельная реализация через phone.call_ended webhook.

Можно ли сохранить нативную интеграцию и добавить кастомную поверх?

Нет - получите дубли встреч в HubSpot. Нативную интеграцию нужно отключить через HubSpot App Marketplace -> Zoom -> Uninstall или Disconnect. Только после этого включать кастомную через Zoom webhook.

Что происходит если участник Zoom-встречи не найден в HubSpot?

Если find_deal_for_host возвращает None - встреча не логируется или логируется только на Contact без Deal. Рекомендуется добавить fallback: создать Contact по email хоста и логировать встречу без Deal с пометкой для ручной обработки. Это предотвращает потерю данных при новых лидах.

Как разобраться если у одного контакта несколько открытых сделок?

Базовое решение берёт последнюю открытую сделку - это работает для большинства случаев в B2B. Более точный подход: добавить Custom Attribute в Zoom (поддерживается в Zoom API для встреч) с Deal ID, который менеджер передаёт при создании встречи. Тогда маппинг однозначный.

Синхронизируются ли Zoom Webinar через эту интеграцию?

Zoom Webinar - отдельный продукт с другими webhook-событиями (webinar.ended, recording.completed с другой схемой). После миграции HubSpot на marketing events framework (январь 2025) вебинары технически попадают в HubSpot через встроенный механизм, но контроль над привязкой к Deal по-прежнему требует кастомной логики.

Итог

Нативная интеграция HubSpot + Zoom логирует встречи на Contact, а не на Deal. Это системное ограничение, а не баг. Правильное решение:

  • Отключить нативную интеграцию Zoom в HubSpot
  • Zoom webhook meeting.ended -> поиск активной Deal по email хоста
  • Создать Meeting Engagement через POST /crm/v3/objects/meetings с ассоциацией к Deal
  • Прикрепить cloud recording через PATCH после события recording.completed
  • Маппинг Zoom Meeting ID -> HubSpot Meeting ID для идемпотентности

Если ваша команда проводит демо через Zoom и история переговоров не видна на странице сделки - опишите задачу команде Exceltic.dev. Разберём архитектуру вашего стека и предложим решение.

Ещё статьи

Все →