HubSpot + Salesloft: почему нативная интеграция теряет контекст сделки в кадансах

HubSpot + Salesloft: почему нативная интеграция теряет контекст сделки в кадансах

Salesloft — leading sales engagement платформа: кадансы (автоматические последовательности звонков/email/LinkedIn), conversation intelligence, deal insights. Нативная интеграция с HubSpot существует и работает из коробки. Но «работает» не значит «правильно» — в типовых enterprise-командах нативная интеграция создаёт три системных проблемы которые невидимы в первые месяцы и болезненны через полгода.

Три проблемы нативной HubSpot + Salesloft интеграции

1. Звонки и письма попадают в Activity, а не в Timeline сделки

Нативная интеграция пишет Salesloft-активности в HubSpot Contact Activity stream. Менеджер открывает сделку (Deal) — и не видит ни одного письма из Salesloft-каданса. Нужно идти в Contact -> Activity и листать всё что когда-либо делалось с этим контактом.

Результат: менеджер по продажам не имеет контекста сделки в момент звонка. Deal timeline пустой. История коммуникации — в другом месте.

2. Кадансы запускаются без проверки стадии Deal

Salesloft запускает каданс на Contact по триггеру (добавлен в лист, тег в CRM). Нативная интеграция не проверяет текущую стадию связанной Deal в HubSpot. Контакт может уже находиться в стадии «Negotiation» или «Closed Won» — а Salesloft продолжает отправлять ему первичный prospecting-каданс.

Результат: клиент которому уже выставлен счёт получает письмо «Привет, вы ещё не знакомы с нашим продуктом?». Репутационный и коммерческий риск.

3. Дубли активностей при двустороннем sync

Нативная интеграция синхронизирует активности в обе стороны. Если менеджер отвечает на письмо из HubSpot — в Salesloft появляется эта активность. Salesloft считает каданс-шаг выполненным и двигает дальше. При следующей sync HubSpot получает дубль активности от Salesloft.

Результат: дублированные записи в Contact activity, некорректная аналитика по email open rates и reply rates.

Почему нативная интеграция устроена именно так

Salesloft и HubSpot интегрированы через Contact-объект как общий знаменатель. Это логично с точки зрения архитектуры: Contact существует в обеих системах, email и phone — его атрибуты. Но в HubSpot продажи ведутся через Deal-объект, не Contact. Deal имеет свой pipeline stage, свой owner, свою timeline.

Нативная интеграция не имеет механизма «привязать активность Salesloft к конкретной Deal». HubSpot API позволяет это через Association API — но нативный коннектор Salesloft не использует его при синхронизации активностей.

Что конкретно теряет бизнес

  • Deal context: менеджер перед звонком не видит историю переписки из каданса
  • Cadence hygiene: кадансы запускаются на контактах из Won/Lost deals
  • Analytics accuracy: reply rate в Salesloft не совпадает с email activity в HubSpot
  • Manager visibility: руководитель не может посмотреть «какие кадансы работают по каким стадиям»

В типичной SDR-команде (10 человек, 500+ контактов в активных кадансах) это означает 3–5 часов в неделю на ручную «уборку» — удаление дублей, остановку кадансов на Won контактах, поиск истории в двух местах.

Правильная архитектура: HubSpot API + Salesloft API

Решение — кастомная bidirectional интеграция через API обеих платформ:

import requests

HS_TOKEN    = "your_hubspot_private_app_token"
SL_TOKEN    = "your_salesloft_api_token"
HS_BASE     = "https://api.hubapi.com"
SL_BASE     = "https://api.salesloft.com/v2"

HS_HEADERS  = {
    "Authorization": f"Bearer {HS_TOKEN}",
    "Content-Type":  "application/json",
}
SL_HEADERS  = {
    "Authorization": f"Bearer {SL_TOKEN}",
    "Content-Type":  "application/json",
}

def get_hs_deal_stage(deal_id: str) -> str:
    resp = requests.get(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HEADERS,
        params={"properties": "dealstage,pipeline"},
    )
    resp.raise_for_status()
    return resp.json().get("properties", {}).get("dealstage", "")

def associate_activity_to_deal(activity_id: str, deal_id: str):
    # Правильный путь: привязать Salesloft-активность к Deal через HubSpot Association API
    resp = requests.put(
        f"{HS_BASE}/crm/v3/objects/notes/{activity_id}/associations/deals/{deal_id}/note_to_deal",
        headers=HS_HEADERS,
    )
    resp.raise_for_status()

def create_hs_note_on_deal(deal_id: str, body: str,
                            activity_type: str = "EMAIL") -> str:
    # Создать Note на Deal (не на Contact) - появляется в Deal Timeline
    payload = {
        "properties": {
            "hs_note_body":      body,
            "hs_timestamp":      str(int(__import__("time").time() * 1000)),
            "hs_activity_type":  activity_type,
        },
        "associations": [
            {
                "to":   {"id": deal_id},
                "types": [{"associationCategory": "HUBSPOT_DEFINED",
                           "associationTypeId": 214}],
            }
        ],
    }
    resp = requests.post(
        f"{HS_BASE}/crm/v3/objects/notes",
        headers=HS_HEADERS,
        json=payload,
    )
    resp.raise_for_status()
    return resp.json().get("id", "")

def should_start_cadence(contact_id: str, cadence_type: str) -> bool:
    # Проверить стадию Deal перед запуском каданса
    deals = get_hs_contact_deals(contact_id)
    BLOCKED_STAGES = {"closedwon", "closedlost", "contractsent", "decisionmakerboughtin"}
    for deal in deals:
        stage = get_hs_deal_stage(deal["id"])
        if stage.lower() in BLOCKED_STAGES:
            return False
    return True

def get_hs_contact_deals(contact_id: str) -> list:
    resp = requests.get(
        f"{HS_BASE}/crm/v3/objects/contacts/{contact_id}/associations/deals",
        headers=HS_HEADERS,
    )
    resp.raise_for_status()
    return resp.json().get("results", [])

Salesloft webhook -> HubSpot Deal Timeline

@app.route("/webhooks/salesloft", methods=["POST"])
def salesloft_webhook():
    payload     = request.json
    event_type  = payload.get("event_type", "")
    data        = payload.get("data", {})

    if event_type in ("email_sent", "call_completed", "email_replied"):
        person_id = data.get("person", {}).get("id")
        sl_email  = data.get("person", {}).get("email_address", "")

        # Найти HubSpot Contact по email
        hs_contact = find_hs_contact_by_email(sl_email)
        if not hs_contact:
            return "", 200

        contact_id = hs_contact["id"]
        deals      = get_hs_contact_deals(contact_id)

        if not deals:
            return "", 200

        # Привязать активность к первой открытой Deal
        deal_id  = deals[0]["id"]
        summary  = format_salesloft_activity(event_type, data)
        create_hs_note_on_deal(deal_id, summary, activity_type="CALL" if "call" in event_type else "EMAIL")

    elif event_type == "cadence_person_added":
        person_email = data.get("person", {}).get("email_address", "")
        cadence_name = data.get("cadence", {}).get("name", "")

        # Проверить - можно ли запускать каданс на этот контакт
        hs_contact = find_hs_contact_by_email(person_email)
        if hs_contact:
            if not should_start_cadence(hs_contact["id"], cadence_name):
                # Остановить каданс через Salesloft API
                person_id = data.get("person", {}).get("id")
                stop_salesloft_cadence(person_id)
                log_blocked_cadence(person_email, cadence_name)

    return "", 200

def stop_salesloft_cadence(person_id: str):
    resp = requests.post(
        f"{SL_BASE}/cadence_memberships",
        headers=SL_HEADERS,
        json={"person_id": person_id, "action": "remove"},
    )
    # Не raise_for_status - если уже нет в кадансе, это ок

def format_salesloft_activity(event_type: str, data: dict) -> str:
    if event_type == "email_sent":
        subject = data.get("email_body", {}).get("subject", "")
        cadence = data.get("cadence", {}).get("name", "")
        return f"Salesloft: отправлено письмо [{cadence}] - тема: {subject}"
    elif event_type == "call_completed":
        duration = data.get("call", {}).get("duration", 0)
        outcome  = data.get("call", {}).get("sentiment", "")
        return f"Salesloft: звонок {duration} сек., итог: {outcome}"
    elif event_type == "email_replied":
        return f"Salesloft: получен ответ на письмо из каданса"
    return f"Salesloft: {event_type}"

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

Enterprise SaaS (US, SDR-команда 12 человек, HubSpot + Salesloft):

  • Проблема: 15–20% контактов в кадансах оказывались из Won/Lost Deals — получали prospecting-письма как новые лиды. Нашли через жалобу существующего клиента.
  • Решение: кастомная прослойка: cadence_person_added webhook -> проверка Deal stage -> stop cadence если Won/Lost/ContractSent.
  • Дополнительно: Salesloft call recordings -> создаются Notes на Deal через HubSpot API. Руководитель видит всю историю каданса в Deal Timeline без переключения на Contact Activity.
  • Результат: 0 случаев отправки prospecting-кадансов Won клиентам за 3 месяца. Deal Timeline заполнен Salesloft-активностями.

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

  • SDR/BDR команды на HubSpot + Salesloft где кадансы идут параллельно с активными сделками
  • RevOps-специалисты которые строят reporting по связке Salesloft performance -> Deal stage
  • Компании где клиенты жаловались на дублированные или неуместные письма из каданса
  • Enterprise-продажи с длинным циклом где Deal Timeline — основной источник truth для менеджера

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

Salesloft имеет официальную интеграцию с HubSpot — зачем кастомная?

Официальная интеграция Salesloft + HubSpot синхронизирует Contact-данные и пишет базовые активности в HubSpot Contact. Она не привязывает активности к Deal Timeline и не проверяет Deal Stage перед запуском каданса. Для команд с простым процессом этого достаточно. Для команд с enterprise-сделками и длинным циклом — нет.

Salesloft webhooks — как настроить?

Salesloft -> Settings -> Webhooks -> Add Webhook. Выбрать события: email.sent, call.completed, email.replied, cadence.membership.created. Указать endpoint URL. Salesloft отправляет POST с JSON payload. Аутентификация входящих — через Shared Secret в заголовке x-salesloft-signature.

Как избежать дублей активностей при кастомной интеграции?

Ключ идемпотентности: хранить salesloft_activity_id в HubSpot Note custom property. Перед созданием Note — проверять нет ли уже Note с этим salesloft_activity_id. Это предотвращает дубли при повторных webhook delivery (Salesloft может доставить webhook дважды при таймаутах).

HubSpot Association API — какой тип ассоциации Note -> Deal?

HubSpot Association Type ID для Note -> Deal: 214. При создании Note через POST /crm/v3/objects/notes передавать в associations объект с associationTypeId: 214. Это гарантирует что Note появится в Deal Timeline, а не только в Contact Activity.

Итого

  • Нативная интеграция: активности -> Contact Activity (не Deal Timeline)
  • Главный риск: кадансы на Won/Lost контактах без проверки стадии сделки
  • Правильная архитектура: Salesloft webhook -> проверка HubSpot Deal Stage -> создание Note на Deal через Association API
  • Идемпотентность: хранить salesloft_activity_id в HubSpot чтобы избежать дублей
  • Association Type ID Note -> Deal: 214

Если у вас HubSpot + Salesloft и вы видите описанные симптомы — опишите ваш objём кадансов и количество SDR. Exceltic.dev аудирует текущую интеграцию и перестроит её с правильной привязкой к Deal Timeline.

Ещё статьи

Все →