Harvest is a time-tracking and invoicing tool popular among agencies and service businesses for billing hours. A native HubSpot + Harvest integration exists - but it doesn’t work the way most RevOps teams expect. The native integration links Harvest Projects to HubSpot Companies at the data level, but time logs (Time Entries) never appear in the HubSpot Deal Timeline. Sales managers and RevOps leads have no visibility into: how many hours were spent on a client, or what the actual margin on a deal looks like once team time is accounted for.
This is a classic anti-pattern: the native integration solves the name-sync problem (Companies) but doesn’t push operational data (Time Entries) into the right context (Deal Timeline).
What the Native Integration Does (and Doesn’t Do)
Does:
- Syncs Companies between HubSpot and Harvest
- Creates a record in the other system when a new client is added to either
- Basic two-way sync of contact data
Does NOT:
- Push Time Entries into HubSpot Deals
- Create Engagements (Notes) with hour totals
- Update custom Deal fields (e.g. “Hours Spent”)
- Trigger alerts when a budget is exceeded
What the Business Loses
An agency runs a project with a 100-hour budget. By the midpoint, 80 hours have been logged - a fact that exists in Harvest but is invisible in the HubSpot Deal. The account manager sees no warning. The project runs over budget - and no one finds out until the invoice goes out. RevOps can’t build a report on deal margin that accounts for actual hours.
The Right Approach: Harvest Webhook -> HubSpot Note in 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"]
# Find an open deal for this company
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
# Return the first open deal
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}h on task '{task_name}' (project: {project_name})."
f" Date: {spent_date}."
f" Note: {notes_text}" if notes_text else ""
)
create_note_in_deal(deal_id, note.strip())
return jsonify({"status": "ok"}), 200
Updating the Custom Deal Field: Total Hours
def update_deal_hours_field(deal_id: str, additional_hours: float):
hs_field = "total_hours_spent" # custom field name in 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)}},
)
Create a custom Total Hours Spent field (type: Number) in HubSpot Deal Properties. Update it on every time_entry.created event.
Setting Up the Harvest Webhook
Harvest Dashboard -> Settings -> Integrations -> Developer Portal -> Webhooks. URL: your endpoint. Events: time_entry.created, time_entry.updated. Harvest signs requests via X-Harvest-Webhook-Signature (HMAC-SHA256 of the body).
Budget Overrun Alert
BUDGET_THRESHOLD_PCT = 0.8 # 80% = alert
def check_budget_alert(deal_id: str, project_id: int):
# Get project budget from Harvest
r = requests.get(f"{HARVEST_BASE}/projects/{project_id}", headers=HARVEST_HDR)
budget_hours = r.json().get("budget", 0) or 0
# Get hours spent from 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}% of hour budget used ({spent}h of {budget_hours}h). Check Harvest.",
)
Who This Is For
Agencies and consulting firms running HubSpot where hourly billing and margin control are critical. RevOps can only see real P&L per deal when Harvest hours flow into HubSpot Deals. This is especially relevant for teams transitioning from time-and-material to value-based pricing - you need a history of time spent per project.
A similar anti-pattern: HubSpot + Loom (video views not in Deal Timeline).
Frequently Asked Questions
Does Harvest API require OAuth or is a Personal Token enough?
For server-to-server integration, a Personal Access Token (PAT) is sufficient: Harvest -> Profile -> Developers -> Personal Access Tokens. PATs don’t expire. OAuth is only needed if the integration acts on behalf of multiple users (multi-tenant). In this case, a single service account is enough.
What if the company name in Harvest and HubSpot don’t match?
Create a custom field in Harvest Project (harvest_company_id) with the HubSpot Company ID as its value. When creating a project in Harvest, populate it manually or via automation triggered when a Deal is created in HubSpot. The lookup then uses ID rather than name - more precise and reliable.
How do you handle deleted or corrected time entries?
Harvest sends a time_entry.deleted webhook - add a handler that subtracts the hours from the custom HubSpot Deal field. For updated entries (time_entry.updated): calculate the delta (new value minus old) and update the field accordingly. Run a full reconciliation against the Harvest API once a day.
Summary
HubSpot + Harvest - time tracking in 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 association - Custom field
total_hours_spenton Deal - cumulative updates viaPATCH - 80%-budget alert: compare Harvest project budget against accumulated hours in HubSpot
- The native integration doesn’t solve this - only a custom webhook handler does
If you need Harvest integrated with HubSpot Deal Timeline, describe your requirements to the Exceltic.dev team.