HubSpot + Zoom: Why the Native Integration Logs Demo Calls to the Contact, Not the Deal

HubSpot + Zoom is the standard stack for B2B teams running discovery calls and demos over Zoom. The native integration takes minutes to set up: connect Zoom in the HubSpot App Marketplace and meetings start syncing. But there is an architectural problem: all meetings are logged against the Contact, not the Deal. For a team where each rep manages 20-30 deals in parallel, this means a complete loss of negotiation context at the deal level.

In HubSpot projects we see the same pattern over and over: the Deal page is empty - not a single meeting, even though five demos and three technical calls took place on that deal. The entire conversation history is buried in the Contact Timeline, mixed together with calls from other deals and inaccessible to the AE who took the deal over from the SDR.

This article explains why the native integration works this way, what specific losses it creates, and how to set up proper sync using Zoom webhooks + the HubSpot Meetings API.

What happens with the native integration

The native HubSpot + Zoom integration works as follows:

  1. A Zoom meeting ends
  2. HubSpot finds the participant by email
  3. It creates a Meeting Engagement on that participant’s Contact
  4. The cloud recording is added as a link in the meeting’s Activity

Result: the Deal -> Activity page is empty. All meetings are visible only in the Contact Timeline. If multiple people with different HubSpot contacts join the Zoom call, each copy of the meeting is attached to its own contact separately - with no unified view at the deal level.

An additional complication: in January 2025, HubSpot migrated Zoom activities to the marketing events framework. Contact-based Zoom properties (Last registered Zoom webinar, Zoom meeting count) stopped working as criteria for filtering contacts and triggering workflows. Teams that segmented contacts by Zoom activity discovered the breakage without any warning.

Three concrete problems

Problem 1 - negotiation history is not visible on the Deal. The AE opens the deal card before a closing call - not a single meeting. They have to navigate to the Contact, scroll through a mixed timeline, and hunt for the relevant demo recordings. If the contact is involved in two concurrent deals, it is impossible to tell which meeting belongs to which deal.

Problem 2 - deal handoffs lose context. The SDR ran three discovery calls over Zoom and documented pain points. The deal is handed to the AE. The Deal page is blank. The SDR has to relay the history verbally or maintain a separate document. This is the classic breakdown in team selling.

Problem 3 - cloud recordings do not sync when email is missing. If a Zoom participant is not authenticated (an external participant with no email in Zoom), the recording never reaches HubSpot at all. Local recordings never sync - only cloud recordings do. Teams with a corporate policy against cloud recording lose all meeting content.

The architectural reason

The native integration uses Contact as the anchor object because it is the only reliable match: the Zoom participant’s email = a Contact in HubSpot. Attaching a meeting to a Deal natively is not possible - there is no signal pointing to a specific deal.

HubSpot officially states: “activities for Contacts will always be associated with the 5 most recent open deals automatically.” In practice this means a meeting appears on a Deal only if it is one of the contact’s five most recent deals and is already assigned. For teams with high volume and long deal cycles, this simply does not work.

Meeting Engagement in HubSpot API terms is an object at /crm/v3/objects/meetings with explicit associations to Deal and Contact. The native integration creates the object but does not add the association to the Deal.

The right solution: Zoom webhook + Meetings API

Disable the native Zoom integration in the HubSpot App Marketplace. Build a custom one using the Zoom webhook and the HubSpot Meetings API.

Step 1 - set up the Zoom webhook:

In the Zoom Marketplace, create an Event Subscription app. Subscribe to the following events:

  • meeting.ended - meeting has ended
  • recording.completed - cloud recording is ready
from flask import Flask, request
import requests, hashlib, hmac, json

app = Flask(__name__)
ZOOM_SECRET_TOKEN = "your_zoom_webhook_secret_token"
HUBSPOT_TOKEN     = "your_hubspot_private_app_token"
HS_BASE           = "https://api.hubapi.com"

@app.route("/zoom/webhook", methods=["POST"])
def zoom_webhook():
    # Zoom webhook verification (HMAC-SHA256)
    ts      = request.headers.get("x-zm-request-timestamp", "")
    sig_hdr = request.headers.get("x-zm-signature", "")
    body    = request.get_data(as_text=True)
    message = f"v0:{ts}:{body}"
    sig     = "v0=" + hmac.new(ZOOM_SECRET_TOKEN.encode(), message.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, sig_hdr):
        return "Unauthorized", 401

    event = request.json
    if event.get("event") == "meeting.ended":
        process_meeting_ended(event["payload"]["object"])
    elif event.get("event") == "recording.completed":
        attach_recording(event["payload"]["object"])
    return "ok", 200

Step 2 - find the Deal by the meeting host’s email:

def find_deal_for_host(host_email: str) -> dict | None:
    hs = requests.Session()
    hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})

    # Find the contact by email
    r = hs.get(f"{HS_BASE}/crm/v3/objects/contacts/{host_email}",
               params={"idProperty": "email", "properties": "firstname,lastname"})
    if r.status_code != 200:
        return None
    contact = r.json()
    contact_id = contact["id"]

    # Find the contact's active deal
    r2 = hs.get(f"{HS_BASE}/crm/v4/objects/contacts/{contact_id}/associations/deals")
    deal_ids = [a["toObjectId"] for a in r2.json().get("results", [])]
    if not deal_ids:
        return None

    # Take the most recent open deal
    for deal_id in deal_ids:
        r3 = hs.get(f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
                    params={"properties": "dealstage,dealname"})
        stage = r3.json().get("properties", {}).get("dealstage", "")
        if stage not in ("closedwon", "closedlost"):
            return {"deal_id": deal_id, "contact_id": contact_id}
    return None

Step 3 - create a Meeting Engagement on the Deal:

def process_meeting_ended(meeting: dict):
    host_email = meeting.get("host_email", "")
    ctx        = find_deal_for_host(host_email)
    if not ctx:
        return  # no active deal for this host - skip

    hs = requests.Session()
    hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})

    start_ms = meeting.get("start_time_unix", 0) * 1000
    end_ms   = start_ms + (meeting.get("duration", 0) * 60 * 1000)

    # Collect participants
    participants = ", ".join([
        p.get("user_email", p.get("name", ""))
        for p in meeting.get("participants", {}).get("registrants", [])
    ])

    payload = {
        "properties": {
            "hs_meeting_title":       meeting.get("topic", "Zoom meeting"),
            "hs_meeting_start_time":  str(start_ms),
            "hs_meeting_end_time":    str(end_ms),
            "hs_meeting_outcome":     "COMPLETED",
            "hs_meeting_location":    f"Zoom: {meeting.get('join_url', '')}",
            "hs_meeting_body":        f"Participants: {participants}\nZoom Meeting ID: {meeting.get('id')}",
        },
        "associations": [
            {
                "to": {"id": ctx["deal_id"]},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 212}]
            },
            {
                "to": {"id": ctx["contact_id"]},
                "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 200}]
            }
        ]
    }

    r = hs.post(f"{HS_BASE}/crm/v3/objects/meetings", json=payload)
    r.raise_for_status()
    meeting_id = r.json()["id"]

    # Save mapping Zoom Meeting ID -> HubSpot Meeting ID
    # for attaching the recording later
    save_mapping(meeting.get("id"), meeting_id, ctx["deal_id"])
    return meeting_id

associationTypeId: 212 is the HUBSPOT_DEFINED type for Meeting -> Deal. 200 is for Meeting -> Contact. Current types are always available via GET /crm/v4/associations/meetings/deals/labels.

Step 4 - attach the cloud recording once it is ready:

def attach_recording(recording: dict):
    zoom_meeting_id = recording.get("id")
    hs_meeting_id   = get_mapping(zoom_meeting_id)  # from the previously saved mapping
    if not hs_meeting_id:
        return

    # Get the first MP4 or transcript
    recording_url = next(
        (f["download_url"] for f in recording.get("recording_files", [])
         if f.get("file_type") == "MP4"),
        ""
    )
    transcript = next(
        (f["download_url"] for f in recording.get("recording_files", [])
         if f.get("file_type") == "TRANSCRIPT"),
        ""
    )

    hs = requests.Session()
    hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})

    body_update = f"Recording: {recording_url}"
    if transcript:
        body_update += f"\nTranscript: {transcript}"

    hs.patch(f"{HS_BASE}/crm/v3/objects/meetings/{hs_meeting_id}", json={
        "properties": {"hs_meeting_body": body_update}
    })

Real-world case

A SaaS company with a team of 6 AEs running 150+ demos per month over Zoom. After connecting the native integration they discovered that every deal page in HubSpot had zero demos. The entire negotiation history existed only in the Contact Timeline.

After deploying the custom integration:

  • Every demo is linked to a specific Deal
  • The cloud recording is accessible directly from the deal page
  • Deal handoffs between SDR and AE - full context preserved
  • Zero duplicates - processing is idempotent by Zoom Meeting ID

Implementation time: 2-3 days. The logic is simple but reliable: one webhook, one mapping, one patch when the recording is ready.

Who this applies to

Any B2B team on HubSpot + Zoom running a discovery -> demo -> technical call -> closing process. If each contact is involved in exactly one deal and you have a single rep on the deal from start to finish, the native integration is acceptable. In every other case, negotiation history will be lost.

A similar problem is described for HubSpot + Aircall and HubSpot + Apollo - this is a systemic characteristic of HubSpot’s native integrations, where Contact is the entry point rather than Deal.

Frequently asked questions

Does this work with Zoom Phone, or only with Zoom Meetings?

Zoom Meetings and Zoom Phone are separate products with different webhook events. Zoom Phone has its own native integration with HubSpot (Using the Zoom Phone for HubSpot integration), which logs calls separately from meetings. The solution described here works for Zoom Meetings (demos, discovery calls, check-ins). Zoom Phone requires a separate implementation using the phone.call_ended webhook.

Can I keep the native integration and add a custom one on top?

No - you will get duplicate meetings in HubSpot. You need to disable the native integration via HubSpot App Marketplace -> Zoom -> Uninstall or Disconnect. Only then enable the custom one via Zoom webhook.

What happens if a Zoom meeting participant is not found in HubSpot?

If find_deal_for_host returns None, the meeting is either not logged or logged only against the Contact without a Deal. It is recommended to add a fallback: create a Contact from the host’s email and log the meeting without a Deal, flagged for manual review. This prevents data loss for new leads.

How should I handle a contact with multiple open deals?

The basic solution takes the most recent open deal - this works for the majority of B2B cases. A more precise approach: add a Custom Attribute in Zoom (supported in the Zoom API for meetings) containing the Deal ID, which the rep sets when creating the meeting. This makes the mapping unambiguous.

Do Zoom Webinars sync through this integration?

Zoom Webinar is a separate product with different webhook events (webinar.ended, recording.completed with a different schema). After HubSpot’s migration to the marketing events framework (January 2025), webinars technically reach HubSpot through the built-in mechanism, but controlling the association to a Deal still requires custom logic.

Summary

The native HubSpot + Zoom integration logs meetings against the Contact, not the Deal. This is a systemic limitation, not a bug. The correct solution:

  • Disable the native Zoom integration in HubSpot
  • Zoom webhook meeting.ended -> find the active Deal by host email
  • Create a Meeting Engagement via POST /crm/v3/objects/meetings with an association to the Deal
  • Attach the cloud recording via PATCH after the recording.completed event
  • Map Zoom Meeting ID -> HubSpot Meeting ID for idempotency

If your team runs demos over Zoom and the negotiation history is not visible on the deal page - describe the challenge to the Exceltic.dev team. We will review your stack architecture and propose a solution.

More articles

All →