HubSpot + Harvest: Why Time Entries Don't Appear in Deals and How to Fix It

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-Signature HMAC-SHA256
  • Find Deal by company name -> POST /crm/v3/objects/notes + associationTypeId: 214 Note-Deal association
  • Custom field total_hours_spent on Deal - cumulative updates via PATCH
  • 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.

More articles

All →