Outreach is a leading sales engagement platform: sequences of emails, calls, and LinkedIn tasks for SDR teams. HubSpot is the CRM where AEs manage deals. A native integration between the two systems exists and is actively promoted by both vendors. In practice it breaks down in three scenarios that are critical for most B2B teams.
The core problem of the native integration: it syncs contacts and activities but does not understand Deal as an object. As a result, a rep sees in HubSpot that a prospect opened emails, but cannot see which sequence they were in or at which step they dropped off.
What Happens: Three Failure Scenarios
Scenario 1 - Duplicate Activities in the Timeline
Outreach sends activities (email opens, clicks, replies) to HubSpot via the native integration. At the same time HubSpot has its own email tracking via Sequences or Sales Hub. If an SDR uses Outreach and emails go from the same domain as HubSpot tracking, duplicates appear in the contact’s Timeline: the same email open is recorded twice.
Consequence: the AE cannot understand the real engagement picture. There are 20 “opens” in the Timeline - in reality there were 10. The contact’s score is inflated.
Scenario 2 - Activities Are Not Linked to the Deal
The native Outreach -> HubSpot integration creates activities (email_activity, call_activity) at the Contact level but does not associate them with a Deal. If a contact has multiple deals (for example, different products), it is unclear which Outreach activities belong to which deal.
Consequence: the Deal Timeline in HubSpot is empty. The AE does not see that the SDR already sent 5 emails. The AE sends another “introductory” email - the prospect is annoyed.
Scenario 3 - Conflict During Contact Property Sync
Outreach and HubSpot sync contact fields in both directions. This creates conflicts:
- An SDR updates
job_titlein Outreach (taken from LinkedIn). HubSpot overwrites it back with the old CRM value. - An AE changes
lifecycle_stagein HubSpot toCustomer. Outreach continues including the contact in SDR sequences (did not receive the update in time). - The
ownerfield syncs by user email. If the Outreach user email does not match HubSpot - the deal owner is assigned incorrectly.
Consequence: a client the AE is already working with as an active deal keeps receiving SDR “introduction” emails.
Why the Native Integration Is Built This Way
Outreach and HubSpot built the integration around the common object - Contact. This is correct for syncing basic information but insufficient for B2B sales where all logic is built around the Deal.
Technical reason: HubSpot Engagements API (now - Activities API) allows associating activities with both Contact and Deal simultaneously. The native Outreach integration only uses the Contact association - simpler and faster to implement, but loses Deal context.
Reason for conflicts: the native integration uses polling (checking for changes every N minutes) instead of webhook-based sync. Polling creates a time lag and data races.
What the Business Loses
Loss 1 - slipped deals. The AE cannot see the full SDR activity for the deal. They make decisions without context: “the prospect is not responding” - even though Outreach shows they opened 6 emails in a row but never clicked. That is a different diagnosis and a different tactic.
Loss 2 - damaged relationships. A client receives SDR prospecting emails a month after becoming an active deal. Trust in the brand drops. According to Outreach data (2024), 23% of lost deals were attributed to “feeling like we were being bombarded with emails.”
Loss 3 - wrong attribution. Reporting in HubSpot does not see Outreach activities as associated with a Deal. Pipeline attribution to SDR activities is distorted. The VP of Sales cannot evaluate the ROI of the SDR team.
The Right Approach: Custom Integration via API
Architecture:
- Abandon the native bidirectional contact sync.
- Outreach -> HubSpot: only via Outreach webhook, only the needed events.
- Every Outreach event is written to HubSpot as an Engagement associated with both Contact and Deal (via Association).
- HubSpot -> Outreach: only lifecycle_stage and deal_stage changes, via HubSpot webhook.
import requests
from typing import Optional
HS_BASE = "https://api.hubapi.com"
def find_deal_for_contact(hs_token: str, contact_id: str) -> Optional[str]:
"""Find the active deal for a contact."""
resp = requests.get(
f"{HS_BASE}/crm/v3/objects/contacts/{contact_id}/associations/deals",
headers={"Authorization": f"Bearer {hs_token}"}
)
if resp.status_code != 200:
return None
results = resp.json().get("results", [])
# Take the first active (not closed) deal
for assoc in results:
deal_id = assoc["id"]
deal = requests.get(
f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
headers={"Authorization": f"Bearer {hs_token}"},
params={"properties": "dealstage,closedate"}
).json()
stage = deal.get("properties", {}).get("dealstage", "")
if stage not in ["closedwon", "closedlost"]:
return deal_id
return None
def log_outreach_activity_to_hubspot(
hs_token: str,
contact_id: str,
deal_id: Optional[str],
activity_type: str, # "EMAIL_OPEN", "EMAIL_REPLY", "CALL"
body: str,
sequence_name: str,
step_number: int
):
"""Write an Outreach activity to HubSpot Engagements."""
engagement_type_map = {
"EMAIL_OPEN": "EMAIL",
"EMAIL_REPLY": "EMAIL",
"CALL": "CALL",
"LINKEDIN_TASK": "TASK",
}
hs_type = engagement_type_map.get(activity_type, "NOTE")
full_body = f"[Outreach] {activity_type}\nSequence: {sequence_name}\nStep: {step_number}\n\n{body}"
associations = [{"objectType": "contact", "objectId": contact_id}]
if deal_id:
associations.append({"objectType": "deal", "objectId": deal_id})
payload = {
"engagement": {"type": hs_type, "timestamp": int(__import__('time').time() * 1000)},
"associations": associations,
"metadata": {"body": full_body},
}
resp = requests.post(
f"{HS_BASE}/engagements/v1/engagements",
headers={
"Authorization": f"Bearer {hs_token}",
"Content-Type": "application/json"
},
json=payload
)
resp.raise_for_status()
def handle_outreach_webhook(event: dict, hs_token: str):
"""Process an Outreach event and write it to HubSpot."""
event_type = event["event_type"] # email.replied, call.completed, etc.
prospect_email = event["prospect"]["email"]
# Find the contact in HubSpot
search = requests.post(
f"{HS_BASE}/crm/v3/objects/contacts/search",
headers={"Authorization": f"Bearer {hs_token}", "Content-Type": "application/json"},
json={"filterGroups": [{"filters": [{"propertyName": "email", "operator": "EQ", "value": prospect_email}]}]}
).json()
contacts = search.get("results", [])
if not contacts:
return # Contact not found
contact_id = contacts[0]["id"]
deal_id = find_deal_for_contact(hs_token, contact_id)
log_outreach_activity_to_hubspot(
hs_token=hs_token,
contact_id=contact_id,
deal_id=deal_id,
activity_type=event_type.upper().replace(".", "_"),
body=event.get("body", ""),
sequence_name=event.get("sequence", {}).get("name", "Unknown"),
step_number=event.get("step_number", 0)
)
Step-by-Step Implementation of the Correct Integration
Step 1 - disable the native sync. In the native Outreach -> HubSpot integration settings, disable automatic activity sync. Keep contact sync only (if needed) with an explicit direction: Outreach -> HubSpot only, not both ways.
Step 2 - set up Outreach webhooks. In Outreach Settings -> Webhooks, add the microservice URL. Select events: prospect.reply, call.completed, sequence.finished, sequence.bounced.
Step 3 - Deal lookup logic. For each Outreach event the service looks up the Deal in HubSpot by Contact. If there are multiple deals - take the most recent active one. Customize the lookup logic to match your process.
Step 4 - associate with Deal. The Engagement is created with associations to both Contact and Deal. This fills the Deal Timeline.
Step 5 - HubSpot -> Outreach (lifecycle_stage). Subscribe to the HubSpot webhook contact.propertyChange for the lifecyclestage property. When it changes to customer - use the Outreach API to update prospect.stage = finished, which stops all active sequences.
Real Case: B2B SaaS, 35 People
A SaaS company with 4 SDRs in Outreach and 5 AEs in HubSpot. The native integration ran for 8 months. Problems accumulated gradually: at some point the VP of Sales noticed that the Deal Timeline was empty for half the deals, even though SDRs had been sending emails.
After the custom integration: every email, call, and LinkedIn task from Outreach appears in the HubSpot Deal Timeline with the sequence name and step. The AE sees the full picture. When the AE changes lifecycle_stage to Customer - the Outreach campaign stops automatically within 2 minutes.
Numbers: 0 “extra” emails to clients over 4 months. Deal Timeline populated in 98% of active deals (was 45%). AE time spent “manually reconciling” data from Outreach and HubSpot dropped from 30 min/day to zero.
Who This Affects
The problem applies to any team where SDRs work in Outreach and AEs work in HubSpot. Especially painful:
- When scaling the SDR team (from 1 to 4+ people)
- When there are multiple products and many deals per contact
- When there are strict pipeline attribution and board reporting requirements
Frequently Asked Questions
Is the native Outreach + HubSpot integration paid?
The native integration is included in both products. A paid Outreach plan is required regardless. HubSpot Sales Hub Professional or Enterprise is needed for full Engagements API access. A custom integration has an additional development cost, but pays off through the team’s time savings.
SalesLoft vs Outreach - the same problem with HubSpot?
Yes. The analysis of the HubSpot + Salesloft integration shows similar problems: the native integration does not associate cadence activities with the Deal. The architectural approach to fixing it is identical.
Can you keep the native contact sync and only make activities custom?
Yes. Native Outreach <-> HubSpot contact sync generally works fine for basic properties. The problem is specifically with activities and Deal association. You can keep native contact sync and add a custom layer only for activities.
How do you exclude duplicates if the native integration already wrote some activities?
When switching to a custom integration a “cleanup” period is needed: disable native activity sync, mark the boundary (the switchover date), do not duplicate historical activities. Old duplicates in HubSpot Timeline must be cleaned up manually or via bulk API requests.
Can Outreach pull data from HubSpot for email personalization?
Yes. Outreach supports Variables - personalization variables taken from Contact/Account properties. With proper sync of key fields from HubSpot to Outreach (via API, not natively), SDRs can personalize emails with CRM data.
Next Step
If you have HubSpot + Outreach and have encountered an empty Deal Timeline or “extra” emails to clients - describe the task to the Exceltic.dev team. We will analyze your architecture and propose a custom integration with proper activity-to-deal association. A typical project takes 2-3 weeks.