HubSpot + Salesloft: why the native integration loses deal context in cadences
Salesloft is a leading sales engagement platform: cadences (automated sequences of calls/emails/LinkedIn), conversation intelligence, deal insights. Native integration with HubSpot exists and works out of the box. But “works” does not mean “correctly” — in typical enterprise teams the native integration creates three systemic problems that are invisible in the first few months and become painful after six.
Three problems with the native HubSpot + Salesloft integration
1. Calls and emails go to Activity, not to the Deal Timeline
The native integration writes Salesloft activities to the HubSpot Contact Activity stream. A manager opens a deal (Deal) — and sees not a single email from the Salesloft cadence. They have to go to Contact -> Activity and scroll through everything that was ever done with that contact.
Result: the sales manager has no deal context at the moment of the call. The Deal timeline is empty. Communication history is somewhere else.
2. Cadences launch without checking the Deal stage
Salesloft launches a cadence on a Contact via a trigger (added to a list, tag in CRM). The native integration does not check the current stage of the associated Deal in HubSpot. A contact may already be in “Negotiation” or “Closed Won” — and Salesloft keeps sending them the initial prospecting cadence.
Result: a customer who has already been invoiced receives an email saying “Hi, have you heard about our product?” Reputational and commercial risk.
3. Duplicate activities with two-way sync
The native integration syncs activities in both directions. If a manager replies to an email from HubSpot — this activity appears in Salesloft. Salesloft marks the cadence step as completed and moves forward. On the next sync HubSpot receives a duplicate activity from Salesloft.
Result: duplicated records in Contact activity, incorrect analytics for email open rates and reply rates.
Why the native integration is designed this way
Salesloft and HubSpot are integrated through the Contact object as the common denominator. This makes architectural sense: Contact exists in both systems, email and phone are its attributes. But in HubSpot, sales are managed through the Deal object, not Contact. A Deal has its own pipeline stage, its own owner, its own timeline.
The native integration has no mechanism to “associate a Salesloft activity with a specific Deal.” HubSpot API allows this through the Association API — but the native Salesloft connector does not use it when syncing activities.
What the business specifically loses
- Deal context: the manager before a call cannot see the correspondence history from the cadence
- Cadence hygiene: cadences launch on contacts from Won/Lost deals
- Analytics accuracy: reply rate in Salesloft does not match email activity in HubSpot
- Manager visibility: the team lead cannot see “which cadences work at which deal stages”
In a typical SDR team (10 people, 500+ contacts in active cadences) this means 3–5 hours per week of manual “cleanup” — removing duplicates, stopping cadences on Won contacts, searching history in two places.
The correct architecture: HubSpot API + Salesloft API
The solution is a custom bidirectional integration via both platforms’ APIs:
import requests
HS_TOKEN = "your_hubspot_private_app_token"
SL_TOKEN = "your_salesloft_api_token"
HS_BASE = "https://api.hubapi.com"
SL_BASE = "https://api.salesloft.com/v2"
HS_HEADERS = {
"Authorization": f"Bearer {HS_TOKEN}",
"Content-Type": "application/json",
}
SL_HEADERS = {
"Authorization": f"Bearer {SL_TOKEN}",
"Content-Type": "application/json",
}
def get_hs_deal_stage(deal_id: str) -> str:
resp = requests.get(
f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
headers=HS_HEADERS,
params={"properties": "dealstage,pipeline"},
)
resp.raise_for_status()
return resp.json().get("properties", {}).get("dealstage", "")
def associate_activity_to_deal(activity_id: str, deal_id: str):
# Correct approach: associate Salesloft activity with Deal via HubSpot Association API
resp = requests.put(
f"{HS_BASE}/crm/v3/objects/notes/{activity_id}/associations/deals/{deal_id}/note_to_deal",
headers=HS_HEADERS,
)
resp.raise_for_status()
def create_hs_note_on_deal(deal_id: str, body: str,
activity_type: str = "EMAIL") -> str:
# Create a Note on Deal (not Contact) - appears in Deal Timeline
payload = {
"properties": {
"hs_note_body": body,
"hs_timestamp": str(int(__import__("time").time() * 1000)),
"hs_activity_type": activity_type,
},
"associations": [
{
"to": {"id": deal_id},
"types": [{"associationCategory": "HUBSPOT_DEFINED",
"associationTypeId": 214}],
}
],
}
resp = requests.post(
f"{HS_BASE}/crm/v3/objects/notes",
headers=HS_HEADERS,
json=payload,
)
resp.raise_for_status()
return resp.json().get("id", "")
def should_start_cadence(contact_id: str, cadence_type: str) -> bool:
# Check Deal stage before launching cadence
deals = get_hs_contact_deals(contact_id)
BLOCKED_STAGES = {"closedwon", "closedlost", "contractsent", "decisionmakerboughtin"}
for deal in deals:
stage = get_hs_deal_stage(deal["id"])
if stage.lower() in BLOCKED_STAGES:
return False
return True
def get_hs_contact_deals(contact_id: str) -> list:
resp = requests.get(
f"{HS_BASE}/crm/v3/objects/contacts/{contact_id}/associations/deals",
headers=HS_HEADERS,
)
resp.raise_for_status()
return resp.json().get("results", [])
Salesloft webhook -> HubSpot Deal Timeline
@app.route("/webhooks/salesloft", methods=["POST"])
def salesloft_webhook():
payload = request.json
event_type = payload.get("event_type", "")
data = payload.get("data", {})
if event_type in ("email_sent", "call_completed", "email_replied"):
person_id = data.get("person", {}).get("id")
sl_email = data.get("person", {}).get("email_address", "")
# Find HubSpot Contact by email
hs_contact = find_hs_contact_by_email(sl_email)
if not hs_contact:
return "", 200
contact_id = hs_contact["id"]
deals = get_hs_contact_deals(contact_id)
if not deals:
return "", 200
# Associate activity with the first open Deal
deal_id = deals[0]["id"]
summary = format_salesloft_activity(event_type, data)
create_hs_note_on_deal(deal_id, summary, activity_type="CALL" if "call" in event_type else "EMAIL")
elif event_type == "cadence_person_added":
person_email = data.get("person", {}).get("email_address", "")
cadence_name = data.get("cadence", {}).get("name", "")
# Check whether a cadence can be launched for this contact
hs_contact = find_hs_contact_by_email(person_email)
if hs_contact:
if not should_start_cadence(hs_contact["id"], cadence_name):
# Stop cadence via Salesloft API
person_id = data.get("person", {}).get("id")
stop_salesloft_cadence(person_id)
log_blocked_cadence(person_email, cadence_name)
return "", 200
def stop_salesloft_cadence(person_id: str):
resp = requests.post(
f"{SL_BASE}/cadence_memberships",
headers=SL_HEADERS,
json={"person_id": person_id, "action": "remove"},
)
# Do not raise_for_status - if already not in cadence, that is fine
def format_salesloft_activity(event_type: str, data: dict) -> str:
if event_type == "email_sent":
subject = data.get("email_body", {}).get("subject", "")
cadence = data.get("cadence", {}).get("name", "")
return f"Salesloft: email sent [{cadence}] - subject: {subject}"
elif event_type == "call_completed":
duration = data.get("call", {}).get("duration", 0)
outcome = data.get("call", {}).get("sentiment", "")
return f"Salesloft: call {duration} sec., outcome: {outcome}"
elif event_type == "email_replied":
return f"Salesloft: reply received to cadence email"
return f"Salesloft: {event_type}"
Real-world case
Enterprise SaaS (US, SDR team of 12, HubSpot + Salesloft):
- Problem: 15–20% of contacts in cadences turned out to be from Won/Lost Deals — receiving prospecting emails as new leads. Discovered via a complaint from an existing customer.
- Solution: custom middleware layer:
cadence_person_addedwebhook -> Deal stage check -> stop cadence if Won/Lost/ContractSent. - Additionally: Salesloft call recordings -> Notes created on the Deal via HubSpot API. Team lead sees the full cadence history in the Deal Timeline without switching to Contact Activity.
- Result: 0 prospecting cadences sent to Won customers over 3 months. Deal Timeline populated with Salesloft activities.
Who this is relevant for
- SDR/BDR teams on HubSpot + Salesloft where cadences run in parallel with active deals
- RevOps specialists building reporting on the Salesloft performance -> Deal stage relationship
- Companies where customers have complained about duplicate or inappropriate cadence emails
- Enterprise sales with long cycles where Deal Timeline is the manager’s primary source of truth
Frequently asked questions
Salesloft has an official integration with HubSpot — why go custom?
The official Salesloft + HubSpot integration syncs Contact data and writes basic activities to HubSpot Contact. It does not associate activities with the Deal Timeline and does not check Deal Stage before launching a cadence. For teams with a simple process this is sufficient. For teams with enterprise deals and a long cycle — it is not.
Salesloft webhooks — how to configure?
Salesloft -> Settings -> Webhooks -> Add Webhook. Select events: email.sent, call.completed, email.replied, cadence.membership.created. Specify the endpoint URL. Salesloft sends a POST with JSON payload. Authentication of incoming requests — via Shared Secret in the x-salesloft-signature header.
How to avoid duplicate activities with custom integration?
Idempotency key: store salesloft_activity_id in a HubSpot Note custom property. Before creating a Note — check whether a Note with this salesloft_activity_id already exists. This prevents duplicates on repeated webhook delivery (Salesloft may deliver a webhook twice on timeouts).
HubSpot Association API — what association type is Note -> Deal?
HubSpot Association Type ID for Note -> Deal: 214. When creating a Note via POST /crm/v3/objects/notes pass an associations object with associationTypeId: 214. This guarantees the Note appears in the Deal Timeline, not only in Contact Activity.
Summary
- Native integration: activities -> Contact Activity (not Deal Timeline)
- Primary risk: cadences on Won/Lost contacts without checking deal stage
- Correct architecture: Salesloft webhook -> check HubSpot Deal Stage -> create Note on Deal via Association API
- Idempotency: store
salesloft_activity_idin HubSpot to avoid duplicates - Association Type ID Note -> Deal:
214
If you have HubSpot + Salesloft and see the symptoms described — share your cadence volume and number of SDRs. Exceltic.dev will audit the current integration and rebuild it with proper Deal Timeline association.