HubSpot + Harvest: почему учёт времени не попадает в Deal и как это исправить

Harvest - таймер учёта времени с инвойсингом: популярен в агентствах и сервисных компаниях для биллинга часов. HubSpot + Harvest интеграция существует нативно - но работает не так, как ожидает большинство RevOps-команд. Нативная интеграция связывает Harvest Projects с HubSpot Companies на уровне данных, но логи времени (Time Entries) не появляются в HubSpot Deal Timeline. Менеджер по продажам или RevOps не видит: сколько часов потрачено на клиента, какой реальный маржинальный доход по сделке с учётом времени команды.

Это типовой антипаттерн: нативная интеграция решает задачу синхронизации названий (Companies) но не передаёт операционные данные (Time Entries) в нужный контекст (Deal Timeline).

Что делает нативная интеграция (и чего не делает)

Делает:

  • Синхронизирует Companies между HubSpot и Harvest
  • При создании нового клиента в одной системе - создаёт в другой
  • Базовая двусторонняя синхронизация контактных данных

НЕ делает:

  • Не передаёт Time Entries в HubSpot Deal
  • Не создаёт Engagements (Notes) с суммой часов
  • Не обновляет кастомные поля Deal (например, “Часов потрачено”)
  • Не триггерит уведомления при превышении бюджета

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

Агентство ведёт проект на 100 часов бюджета. К середине проекта потрачено 80 часов - факт, который есть в Harvest, но не виден в HubSpot Deal. Менеджер по работе с клиентами не видит предупреждений. Проект выходит за бюджет - узнают только при выставлении инвойса. RevOps не может построить отчёт: маржинальность сделок с учётом фактических часов.

Правильный подход: Harvest webhook -> HubSpot Note в Deal

import requests, os, hmac, hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)

HS_TOKEN       = os.environ["HUBSPOT_ACCESS_TOKEN"]
HARVEST_SECRET = os.environ.get("HARVEST_WEBHOOK_SECRET", "")
HARVEST_TOKEN  = os.environ["HARVEST_ACCESS_TOKEN"]
HARVEST_ACCT   = os.environ["HARVEST_ACCOUNT_ID"]

HS_BASE      = "https://api.hubapi.com"
HS_HDR       = {"Authorization": f"Bearer {HS_TOKEN}", "Content-Type": "application/json"}
HARVEST_BASE = "https://api.harvestapp.com/v2"
HARVEST_HDR  = {
    "Authorization":     f"Bearer {HARVEST_TOKEN}",
    "Harvest-Account-Id": HARVEST_ACCT,
    "User-Agent":        "HubSpot-Integration/1.0",
}

def verify_harvest_sig(body: bytes, sig: str) -> bool:
    if not HARVEST_SECRET:
        return True
    expected = "sha256=" + hmac.new(HARVEST_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)

def find_deal_by_company_name(company_name: str) -> str | None:
    r = requests.post(
        f"{HS_BASE}/crm/v3/objects/companies/search",
        headers=HS_HDR,
        json={
            "filterGroups": [{"filters": [{
                "propertyName": "name",
                "operator": "EQ",
                "value": company_name,
            }]}],
            "properties": ["name"],
            "limit": 1,
        },
    )
    results = r.json().get("results", []) or []
    if not results:
        return None
    company_id = results[0]["id"]
    # Найти открытую сделку этой компании
    r2 = requests.get(
        f"{HS_BASE}/crm/v3/objects/companies/{company_id}/associations/deals",
        headers=HS_HDR,
    )
    deals = r2.json().get("results", []) or []
    if not deals:
        return None
    # Вернуть первую открытую сделку
    for deal_ref in deals:
        rd = requests.get(
            f"{HS_BASE}/crm/v3/objects/deals/{deal_ref['id']}",
            headers=HS_HDR,
            params={"properties": "dealstage,dealname"},
        )
        stage = rd.json().get("properties", {}).get("dealstage", "")
        if stage not in ("closedwon", "closedlost"):
            return deal_ref["id"]
    return None

def create_note_in_deal(deal_id: str, note_body: str):
    import time
    r = requests.post(
        f"{HS_BASE}/crm/v3/objects/notes",
        headers=HS_HDR,
        json={
            "properties": {
                "hs_note_body": note_body,
                "hs_timestamp": str(int(time.time() * 1000)),
            },
        },
    )
    note_id = r.json().get("id", "")
    if note_id and deal_id:
        requests.put(
            f"{HS_BASE}/crm/v4/objects/notes/{note_id}/associations/deals/{deal_id}",
            headers=HS_HDR,
            json=[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 214}],
        )

def get_harvest_time_entry(entry_id: int) -> dict:
    r = requests.get(f"{HARVEST_BASE}/time_entries/{entry_id}", headers=HARVEST_HDR)
    return r.json() if r.status_code == 200 else {}

@app.route("/webhooks/harvest", methods=["POST"])
def harvest_webhook():
    sig = request.headers.get("X-Harvest-Webhook-Signature", "")
    if not verify_harvest_sig(request.data, sig):
        return jsonify({"error": "invalid signature"}), 401

    event    = request.json or {}
    ev_type  = event.get("type", "")

    if ev_type not in ("time_entry.created", "time_entry.updated"):
        return jsonify({"status": "ignored"}), 200

    entry_id = event.get("data", {}).get("id")
    if not entry_id:
        return jsonify({"status": "no_entry_id"}), 200

    entry        = get_harvest_time_entry(entry_id)
    hours        = entry.get("hours", 0)
    notes_text   = entry.get("notes", "")
    task_name    = entry.get("task", {}).get("name", "")
    project_name = entry.get("project", {}).get("name", "")
    client_name  = entry.get("client", {}).get("name", "")
    spent_date   = entry.get("spent_date", "")

    deal_id = find_deal_by_company_name(client_name)
    if not deal_id:
        return jsonify({"status": "no_deal_found"}), 200

    note = (
        f"Harvest: {hours}ч по задаче '{task_name}' (проект: {project_name})."
        f" Дата: {spent_date}."
        f" Заметка: {notes_text}" if notes_text else ""
    )
    create_note_in_deal(deal_id, note.strip())
    return jsonify({"status": "ok"}), 200

Обновление кастомного поля Deal: итого часов

def update_deal_hours_field(deal_id: str, additional_hours: float):
    hs_field = "total_hours_spent"  # имя кастомного поля в HubSpot
    r = requests.get(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HDR,
        params={"properties": hs_field},
    )
    current = float(r.json().get("properties", {}).get(hs_field) or 0)
    new_val  = round(current + additional_hours, 2)
    requests.patch(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HDR,
        json={"properties": {hs_field: str(new_val)}},
    )

Создайте кастомное поле Total Hours Spent (тип Number) в HubSpot Deal Properties. Обновляйте при каждом time_entry.created.

Настройка Harvest webhook

Harvest Dashboard -> Settings -> Integrations -> Developer Portal -> Webhooks. URL: ваш endpoint. Events: time_entry.created, time_entry.updated. Harvest подписывает через X-Harvest-Webhook-Signature (HMAC-SHA256 тела).

Алерт при превышении бюджета

BUDGET_THRESHOLD_PCT = 0.8  # 80% = алерт

def check_budget_alert(deal_id: str, project_id: int):
    # Получить бюджет проекта из Harvest
    r = requests.get(f"{HARVEST_BASE}/projects/{project_id}", headers=HARVEST_HDR)
    budget_hours = r.json().get("budget", 0) or 0

    # Получить потраченные часы из HubSpot
    rd = requests.get(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HDR,
        params={"properties": "total_hours_spent"},
    )
    spent = float(rd.json().get("properties", {}).get("total_hours_spent") or 0)

    if budget_hours > 0 and spent / budget_hours >= BUDGET_THRESHOLD_PCT:
        pct = int(spent / budget_hours * 100)
        create_note_in_deal(
            deal_id,
            f"ALERT: {pct}% бюджета часов использовано ({spent}h из {budget_hours}h). Проверьте Harvest.",
        )

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

Агентства и консалтинговые компании на HubSpot, где биллинг по часам и контроль маржинальности критичны. RevOps видит реальный P&L по каждой сделке только если часы из Harvest попадают в HubSpot Deal. Особенно актуально для перехода с time & material на value-based pricing - нужна история затрат времени по проектам.

Аналогичный антипаттерн: HubSpot + Loom (просмотры видео не в Deal).

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

Harvest API требует OAuth или достаточно Personal Token?

Для server-to-server интеграции достаточно Personal Access Token (PAT): Harvest -> Profile -> Developers -> Personal Access Tokens. PAT не истекает. OAuth нужен только если интеграция действует от имени разных пользователей (multi-tenant). В данном случае - один сервис-аккаунт достаточно.

Как находить Deal если имя компании в Harvest и HubSpot различаются?

Создайте кастомное поле в Harvest Project (harvest_company_id) со значением HubSpot Company ID. При создании проекта в Harvest - заполняйте вручную или через автоматизацию при создании Deal в HubSpot. Тогда lookup идёт по ID, не по имени - точнее и надёжнее.

Как обрабатывать удалённые или откорректированные time entries?

Harvest отправляет webhook time_entry.deleted - добавьте обработчик, который вычитает часы из кастомного поля HubSpot Deal. Для обновлённых entries (time_entry.updated): пересчитайте разницу (новое значение - старое) и обновите поле. Полный пересчёт через Harvest API раз в сутки как reconciliation.

Итог

HubSpot + Harvest - учёт времени в Deal Timeline:

  • Harvest webhook time_entry.created/updated -> X-Harvest-Webhook-Signature HMAC-SHA256
  • find Deal by company name -> POST /crm/v3/objects/notes + associationTypeId: 214 Note-Deal
  • Кастомное поле total_hours_spent на Deal - накопительное обновление через PATCH
  • 80%-бюджет алерт: сравнить Harvest project budget vs накопленные часы в HubSpot
  • Нативная интеграция не решает это - только кастомный webhook-обработчик

Если нужна интеграция Harvest с HubSpot Deal Timeline - опишите задачу команде Exceltic.dev.

Ещё статьи

Все →