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-SignatureHMAC-SHA256 - find Deal by company name ->
POST /crm/v3/objects/notes+associationTypeId: 214Note-Deal - Кастомное поле
total_hours_spentна Deal - накопительное обновление черезPATCH - 80%-бюджет алерт: сравнить Harvest project budget vs накопленные часы в HubSpot
- Нативная интеграция не решает это - только кастомный webhook-обработчик
Если нужна интеграция Harvest с HubSpot Deal Timeline - опишите задачу команде Exceltic.dev.