HubSpot + Apollo: почему нативная интеграция создаёт дубли и теряет контекст

Симптомы: что происходит с вашими данными

Вы подключили Apollo к HubSpot через нативную интеграцию. Кажется, что всё работает: контакты из Apollo появляются в HubSpot. Но через несколько недель картина становится тревожной:

  • В HubSpot есть «John Smith» из Acme Corp с email john@acme.com - и ещё один «John Smith» тоже с john@acme.com, созданный Apollo
  • Сделки в HubSpot не связаны с activity из Apollo sequences
  • В Apollo есть поле «Apollo Score» (AI-оценка перспективности контакта) - в HubSpot его нет
  • В HubSpot timeline не видно, что контакт открывал письма в Apollo sequences
  • SDR открывает карточку контакта в HubSpot - и не знает, что коллега уже написал ему из Apollo три дня назад

Всё это - прямые следствия архитектурных ограничений нативной интеграции Apollo + HubSpot.

Корневые причины проблем

1. Дубли контактов - матчинг только по email

Когда Apollo создаёт контакт в HubSpot (при добавлении в sequence), нативная интеграция проверяет дубли только по полю email. Если в HubSpot уже есть контакт john@acme.com - интеграция должна найти его и не создавать нового.

Проблема: HubSpot хранит email как primary + secondary. Apollo проверяет только primary email. Если контакт в HubSpot имеет корпоративный email как secondary (а личный - как primary), Apollo создаст дубль.

Вторая проблема: некоторые компании используют email aliases. john@acme.com и j.smith@acme.com - разные адреса с точки зрения нативной интеграции, но один человек. Apollo не выполняет матчинг по имени + домену компании.

2. Нет привязки к существующим HubSpot Deals

Native Apollo <-> HubSpot интеграция создаёт/обновляет Contact и Company. Deal в HubSpot она не трогает.

Результат: Apollo добавляет контакт в sequence «Enterprise Outreach». Этот же контакт уже имеет открытую сделку в HubSpot у другого менеджера. Нативная интеграция не видит эту связь. В HubSpot нет уведомления «коллега работает с этим лидом в Apollo».

3. Apollo custom properties не передаются в HubSpot

Apollo имеет набор собственных полей, которые ценны для приоритизации:

  • apollo_score - AI-оценка перспективности (0-100)
  • seniority - уровень в компании (C-level, VP, Manager)
  • linkedin_url - LinkedIn профиль
  • keywords - технологии, которые использует компания
  • technologies - технологический стек (из Apollo Intent data)

Нативная интеграция синхронизирует базовые поля: First Name, Last Name, Email, Title, Company. Apollo-специфичные поля остаются только в Apollo.

4. Sequence activity не попадает в HubSpot engagement timeline

HubSpot Timeline - это история взаимодействий с контактом: звонки, emails, meetings. Нативная интеграция Apollo НЕ записывает в Timeline:

  • Факт добавления в sequence
  • Письма, отправленные из Apollo
  • Открытия и клики писем Apollo
  • Ответы на письма Apollo

SDR, открывая контакт в HubSpot, видит пустой Timeline - хотя коллега уже отправил 3 письма через Apollo и получил автоответ «out of office».

Анализ потерь данных

Оцените масштаб проблемы на вашей базе:

import os
import requests
from collections import defaultdict

HUBSPOT_TOKEN = os.environ["HUBSPOT_PRIVATE_APP_TOKEN"]
HUBSPOT_BASE = "https://api.hubapi.com"


def find_duplicate_contacts() -> dict:
    """Находим дубли контактов в HubSpot по email."""
    headers = {"Authorization": f"Bearer {HUBSPOT_TOKEN}"}
    email_to_contacts: defaultdict[str, list] = defaultdict(list)
    after = None

    while True:
        params = {
            "limit": 100,
            "properties": "email,firstname,lastname,createdate,hs_analytics_source",
        }
        if after:
            params["after"] = after

        r = requests.get(
            f"{HUBSPOT_BASE}/crm/v3/objects/contacts",
            params=params,
            headers=headers,
            timeout=15,
        )
        if not r.ok:
            break

        data = r.json()
        contacts = data.get("results", [])

        for contact in contacts:
            email = contact["properties"].get("email", "")
            if email:
                email_to_contacts[email.lower()].append({
                    "id": contact["id"],
                    "name": f"{contact['properties'].get('firstname', '')} {contact['properties'].get('lastname', '')}",
                    "created": contact["properties"].get("createdate"),
                    "source": contact["properties"].get("hs_analytics_source"),
                })

        paging = data.get("paging")
        if paging and paging.get("next"):
            after = paging["next"]["after"]
        else:
            break

    duplicates = {
        email: contacts
        for email, contacts in email_to_contacts.items()
        if len(contacts) > 1
    }
    return duplicates


duplicates = find_duplicate_contacts()
print(f"Дублей найдено: {len(duplicates)}")
for email, contacts in list(duplicates.items())[:5]:
    print(f"  {email}: {len(contacts)} записей")
    for c in contacts:
        print(f"    ID={c['id']}, source={c['source']}, created={c['created']}")

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

Кастомная интеграция Apollo + HubSpot через API обеих систем решает все проблемы:

Шаг 1. Двусторонний матчинг при синхронизации контактов

import re

APOLLO_API_KEY = os.environ["APOLLO_API_KEY"]
APOLLO_BASE = "https://api.apollo.io/v1"


def search_hubspot_contact(email: str, domain: str, name: str) -> str | None:
    """Ищем контакт в HubSpot по email, затем по домену+имени."""
    headers = {"Authorization": f"Bearer {HUBSPOT_TOKEN}"}

    # Поиск по точному email
    r = requests.post(
        f"{HUBSPOT_BASE}/crm/v3/objects/contacts/search",
        json={
            "filterGroups": [{
                "filters": [{
                    "propertyName": "email",
                    "operator": "EQ",
                    "value": email,
                }]
            }],
            "properties": ["email", "firstname", "lastname", "associatedcompanyid"],
        },
        headers=headers,
        timeout=10,
    )
    if r.ok:
        results = r.json().get("results", [])
        if results:
            return results[0]["id"]

    # Поиск по домену + имени (fallback)
    if domain and name:
        domain_r = requests.post(
            f"{HUBSPOT_BASE}/crm/v3/objects/contacts/search",
            json={
                "filterGroups": [{
                    "filters": [
                        {"propertyName": "email", "operator": "CONTAINS_TOKEN", "value": f"*@{domain}"},
                        {"propertyName": "lastname", "operator": "EQ",
                         "value": name.split()[-1] if name.split() else ""},
                    ]
                }],
                "properties": ["email", "firstname", "lastname"],
            },
            headers=headers,
            timeout=10,
        )
        if domain_r.ok:
            results = domain_r.json().get("results", [])
            if results:
                return results[0]["id"]

    return None


def sync_apollo_contact_to_hubspot(apollo_contact: dict) -> str:
    """Синхронизируем Apollo-контакт в HubSpot с полным набором полей."""
    email = apollo_contact.get("email", "")
    domain = email.split("@")[1] if "@" in email else ""
    name = f"{apollo_contact.get('first_name', '')} {apollo_contact.get('last_name', '')}".strip()

    existing_id = search_hubspot_contact(email, domain, name)

    headers = {
        "Authorization": f"Bearer {HUBSPOT_TOKEN}",
        "Content-Type": "application/json",
    }

    properties = {
        "email": email,
        "firstname": apollo_contact.get("first_name", ""),
        "lastname": apollo_contact.get("last_name", ""),
        "jobtitle": apollo_contact.get("title", ""),
        "phone": apollo_contact.get("phone", ""),
        "linkedin_url": apollo_contact.get("linkedin_url", ""),
        # Кастомные свойства Apollo - должны быть созданы в HubSpot заранее
        "apollo_score": str(apollo_contact.get("score", 0)),
        "apollo_seniority": apollo_contact.get("seniority", ""),
        "apollo_technologies": ", ".join(
            apollo_contact.get("account", {}).get("technologies", [])[:10]
        ),
    }

    if existing_id:
        # Обновляем существующий
        r = requests.patch(
            f"{HUBSPOT_BASE}/crm/v3/objects/contacts/{existing_id}",
            json={"properties": properties},
            headers=headers,
            timeout=10,
        )
        return existing_id
    else:
        # Создаём новый
        r = requests.post(
            f"{HUBSPOT_BASE}/crm/v3/objects/contacts",
            json={"properties": properties},
            headers=headers,
            timeout=10,
        )
        if r.ok:
            return r.json()["id"]
    return ""


def log_apollo_sequence_activity(
    hubspot_contact_id: str,
    sequence_name: str,
    event_type: str,
    email_subject: str,
    occurred_at: str,
):
    """Записываем Apollo sequence activity в HubSpot Timeline."""
    # Нужно создать Custom Timeline Event Type в HubSpot заранее
    TIMELINE_EVENT_TYPE_ID = os.environ.get("HUBSPOT_APOLLO_TIMELINE_TYPE_ID", "")
    APP_ID = os.environ.get("HUBSPOT_APP_ID", "")

    if not TIMELINE_EVENT_TYPE_ID or not APP_ID:
        return

    r = requests.put(
        f"{HUBSPOT_BASE}/integrations/v1/{APP_ID}/timeline/event",
        headers={
            "Authorization": f"Bearer {HUBSPOT_TOKEN}",
            "Content-Type": "application/json",
        },
        json={
            "eventTypeId": TIMELINE_EVENT_TYPE_ID,
            "id": f"apollo_{sequence_name}_{event_type}_{hubspot_contact_id}_{occurred_at}",
            "objectId": hubspot_contact_id,
            "occurredAt": occurred_at,
            "extraData": {
                "sequence_name": sequence_name,
                "event_type": event_type,
                "email_subject": email_subject,
            },
        },
        timeout=10,
    )
    return r.ok

Шаг 2. Привязка к существующей Deal

def associate_contact_to_deal_if_exists(
    hubspot_contact_id: str, deal_search_email: str
):
    """Привязываем контакт к существующей открытой сделке если она есть."""
    headers = {
        "Authorization": f"Bearer {HUBSPOT_TOKEN}",
        "Content-Type": "application/json",
    }

    # Ищем открытые сделки контакта
    r = requests.get(
        f"{HUBSPOT_BASE}/crm/v3/objects/contacts/{hubspot_contact_id}/associations/deals",
        headers=headers,
        timeout=10,
    )
    if r.ok:
        deals = r.json().get("results", [])
        if deals:
            # Контакт уже привязан к сделке - не создаём дубль
            print(f"Contact {hubspot_contact_id} already has {len(deals)} deal(s)")
            return deals[0]["id"]

    # Сделок нет - можно создать или просто оставить
    return None

Для кого подходит кастомная интеграция

Кастомная интеграция Apollo + HubSpot нужна компаниям:

  • С базой HubSpot от 5000 контактов, где проблема дублей уже ощутима
  • Которые используют Apollo Intent Data и Apollo Score для приоритизации - и хотят эти данные видеть в HubSpot
  • Где SDR работают в Apollo, а AE и CSM работают в HubSpot - нужна единая история
  • Где compliance-требования (GDPR, SOC2) требуют полного аудита всех коммуникаций в одной системе

Если вас интересуют другие антипаттерны HubSpot - читайте про HubSpot + Zendesk и HubSpot + Slack.

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

Нативная интеграция Apollo - это платная функция? HubSpot интеграция в Apollo доступна на планах Professional и выше. На Basic плане Apollo доступны только ручной экспорт CSV и базовая синхронизация через Zapier.

Можно ли очистить существующие дубли перед переходом на кастомную интеграцию? Да. HubSpot имеет встроенный инструмент Merge Contacts (Settings -> Data Management -> Duplicates). Для массовой очистки используйте HubSpot API: найдите дубли скриптом выше, затем вызовите POST /crm/v3/objects/contacts/merge для каждой пары.

Apollo API - это платный доступ? Apollo REST API доступен на платных планах (Basic от $49/month). На бесплатном плане API доступа нет. Для кастомной интеграции нужен как минимум Basic план Apollo.

Как обеспечить GDPR-compliance при синхронизации данных? Apollo собирает данные из открытых источников. При синхронизации в HubSpot убедитесь, что у контакта есть lawful basis для обработки данных (legitimate interest для B2B prospecting в EU допустим, но должен быть задокументирован). Не синхронизируйте контакты без явного opt-in в страны с жёстким законодательством (Германия, Нидерланды).

Если у вас проблемы с нативной интеграцией HubSpot + Apollo - опишите симптомы команде Exceltic.dev. Разберём архитектуру и предложим решение без дублей и потери данных.

Ещё статьи

Все →