HubSpot + Loom: Why Video Views Don't Appear in Deal Timeline and How to Fix It

HubSpot and Loom have a native integration: embed Loom videos in HubSpot Email Sequences and track clicks. Sales teams use this for video prospecting - record a personalized video, send it via a Sequence, and see when a prospect watched it. The problem: the standard integration only captures the click on the video preview in the email, not the actual video view. More importantly, neither the click nor the view appears in the Deal Timeline. Activity is visible in Contact Activity, but it is not associated with the deal.

This is an architectural limitation of the native integration: Loom for HubSpot is a Chrome plugin for embedding videos in emails, not a two-way API integration. Loom does not send events to HubSpot every time a video is watched.

What the Sales Team Loses

  • Managers cannot see in the deal card: who on the client’s team watched the video, when, and how many times
  • RevOps cannot build a report: do deals with a Loom view convert at a higher rate?
  • No trigger for follow-up: “client watched video -> create a call task”

This is not a “nice to have” - for teams that actively use video selling (Loom in every touch), losing this context in the Deal breaks the entire workflow.

Why the Native Integration Works This Way

The Loom native integration with HubSpot is a HubSpot Sales Extension: it embeds a video preview in the email body via the HubSpot Email editor. When the prospect clicks the preview, HubSpot logs an Email Click event. This is standard email tracking behavior - not specific to Loom.

Loom does not notify HubSpot about views on its own platform. View data (viewer email, watch percentage, rewatch) is stored in Loom Analytics and is not available to HubSpot in real time.

The Right Approach: Loom Webhook -> HubSpot Engagement

Loom provides webhooks for events: video.viewed, video.completed, video.shared. When a webhook is configured, Loom sends view data to your endpoint - including the viewer’s email (if known) and the video_id.

Loom webhook: video.viewed
  {viewer_email, video_id, watch_time_percentage, timestamp}
  -> Your server

Your server
  -> HubSpot API: find Contact by viewer_email
  -> HubSpot API: find open Deal for that Contact
  -> HubSpot API: create Engagement (Note) in Deal Timeline
     "Loom viewed: {video_title}, {watch_pct}%, {duration}s"

Implementation

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

app = Flask(__name__)

HS_TOKEN     = os.environ["HUBSPOT_ACCESS_TOKEN"]
LOOM_SECRET  = os.environ["LOOM_WEBHOOK_SECRET"]
HS_BASE      = "https://api.hubapi.com"
HS_HDR       = {"Authorization": f"Bearer {HS_TOKEN}", "Content-Type": "application/json"}

def verify_loom_sig(body: bytes, sig: str) -> bool:
    if not LOOM_SECRET:
        return True
    expected = hmac.new(LOOM_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", sig)

def find_contact_id(email: str) -> str | None:
    r = requests.post(
        f"{HS_BASE}/crm/v3/objects/contacts/search",
        headers=HS_HDR,
        json={
            "filterGroups": [{"filters": [{
                "propertyName": "email",
                "operator": "EQ",
                "value": email,
            }]}],
            "properties": ["email"],
            "limit": 1,
        },
    )
    results = r.json().get("results", []) or []
    return results[0]["id"] if results else None

def find_deal_for_contact(contact_id: str) -> str | None:
    r = requests.get(
        f"{HS_BASE}/crm/v3/objects/contacts/{contact_id}/associations/deals",
        headers=HS_HDR,
    )
    results = r.json().get("results", []) or []
    if not results:
        return None
    deal_id = results[0]["id"]
    # Check that the deal is open
    rd = requests.get(
        f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
        headers=HS_HDR,
        params={"properties": "dealstage,closedate"},
    )
    stage = rd.json().get("properties", {}).get("dealstage", "")
    closed_stages = {"closedwon", "closedlost"}
    if stage in closed_stages:
        return None
    return deal_id

def create_note_in_deal(deal_id: str, note_text: str):
    # Step 1: create Engagement (Note)
    r = requests.post(
        f"{HS_BASE}/crm/v3/objects/notes",
        headers=HS_HDR,
        json={
            "properties": {
                "hs_note_body":      note_text,
                "hs_timestamp":      str(int(__import__("time").time() * 1000)),
            },
        },
    )
    r.raise_for_status()
    note_id = r.json().get("id", "")

    # Step 2: associate Note with Deal
    requests.put(
        f"{HS_BASE}/crm/v4/objects/notes/{note_id}/associations/deals/{deal_id}",
        headers=HS_HDR,
        json=[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 214}],
    )

@app.route("/webhooks/loom", methods=["POST"])
def loom_webhook():
    sig = request.headers.get("X-Loom-Signature", "")
    if not verify_loom_sig(request.data, sig):
        return jsonify({"error": "invalid signature"}), 401

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

    if ev_type not in ("video.viewed", "video.completed"):
        return jsonify({"status": "ignored"}), 200

    data         = event.get("data", {})
    viewer_email = data.get("viewer_email", "")
    video_title  = data.get("video_title", "Loom video")
    watch_pct    = data.get("watch_percentage", 0)
    duration     = data.get("duration_seconds", 0)
    video_url    = data.get("video_url", "")

    if not viewer_email:
        return jsonify({"status": "no_email"}), 200

    contact_id = find_contact_id(viewer_email)
    if not contact_id:
        return jsonify({"status": "contact_not_found"}), 200

    deal_id = find_deal_for_contact(contact_id)
    if not deal_id:
        return jsonify({"status": "no_open_deal"}), 200

    pct_str = f"{watch_pct:.0f}%" if watch_pct else "unknown"
    note    = (
        f"Loom: {viewer_email} watched '{video_title}' "
        f"({pct_str}, {duration}s). "
        f"{video_url}"
    )
    create_note_in_deal(deal_id, note)
    return jsonify({"status": "ok"}), 200

Configuring the Loom Webhook

Loom Dashboard -> Settings -> Webhooks. Available on Loom Business and Enterprise plans. Events: video.viewed, video.completed. URL: your endpoint. Secret: used in X-Loom-Signature.

Loom sends a webhook on every view, including repeat views. If a prospect watches the video 3 times, you get 3 events. You can append (rewatch) to note_text if watch_percentage was already close to 100% in the previous event (requires additional deduplication logic).

Trigger for a Follow-Up Task

def create_followup_task(contact_id: str, deal_id: str, viewer_email: str):
    r = requests.post(
        f"{HS_BASE}/crm/v3/objects/tasks",
        headers=HS_HDR,
        json={
            "properties": {
                "hs_task_body":    f"Follow up: {viewer_email} watched the Loom video. Time to call.",
                "hs_task_subject": "Call after Loom view",
                "hs_task_status":  "NOT_STARTED",
                "hs_task_type":    "CALL",
                "hs_timestamp":    str(int(__import__("time").time() * 1000) + 3600000),  # +1h
            },
        },
    )
    task_id = r.json().get("id", "")
    if task_id and deal_id:
        requests.put(
            f"{HS_BASE}/crm/v4/objects/tasks/{task_id}/associations/deals/{deal_id}",
            headers=HS_HDR,
            json=[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 216}],
        )

Who This Is For

HubSpot sales teams that actively use Loom for personalized video prospecting - especially when video selling is the primary touchpoint in email sequences and you need a view history in the Deal Timeline to gauge engagement and build follow-up tasks.

A similar anti-pattern is covered for HubSpot + Drift.

Frequently Asked Questions

Does Loom always include the viewer’s email in the webhook?

No. viewer_email is only present if the viewer is logged into Loom when watching, or if the link was opened from an email client with tracking. If the video is embedded on a website or opened anonymously, the email will be empty. For prospecting (personalized links in emails), the viewer is typically identified.

How do you handle multiple open deals for a single contact?

find_deal_for_contact in the example takes the first one. For precise association: add a custom hs_deal_id parameter to the Loom video URL (via Loom Custom Link Parameters, Enterprise plan). Alternatively, link the video to a specific deal via a custom field at send time.

Is HubSpot Enterprise required for this integration?

No. The HubSpot Notes API (creating a Note and associating it with a Deal) works on all paid HubSpot CRM plans (Starter and above). Loom webhooks are available on Loom Business and Enterprise plans ($12.50/user/month and above).

Summary

HubSpot + Loom - video events in the Deal Timeline:

  • Native integration: Email Click only, not a video view
  • Loom webhook video.viewed -> find Contact by email -> find open Deal -> Note
  • Note on Deal via CRM v3: POST /crm/v3/objects/notes + PUT associations/deals
  • associationTypeId: 214 for Note -> Deal
  • Optional: CREATE TASK via associationTypeId: 216 Task -> Deal for follow-up

If you need a Loom + HubSpot Deal Timeline integration, describe your requirements to the Exceltic.dev team.

More articles

All →