HubSpot + Aircall is a common pairing in B2B teams. The native integration activates with a single click in the Aircall Marketplace. But there is an architectural problem: all calls are logged on the Contact, not on the Deal. This is critical for teams where deals are managed by multiple reps or where a single contact is involved in several parallel deals.
What Happens with the Native Integration
The native Aircall -> HubSpot integration works as follows:
- Call ends -> Aircall finds the contact in HubSpot by phone number
- Creates a Call Engagement on the found Contact
- The call recording is attached to the Contact Activity
Result: on the Deal -> Activity page there are no calls at all. All calls are hidden in the Contact Timeline. To see the call history for a deal, a rep has to open the Contact card and manually scroll through Activity - mixed with calls from other deals.
Three Concrete Problems
Problem 1 - wrong association with multiple deals. A contact is involved in two deals: one active, one won. A call on the active deal ends up in the Contact Timeline with no link to the specific Deal. Analyzing calls per deal becomes impossible.
Problem 2 - call recording unavailable in the Deal context. A manager reviews a Deal before a client call - no call history. They have to switch to Contact, scroll through the timeline, find the right call. That is 3-5 minutes of extra time per call.
Problem 3 - SDR hands off a deal to AE, history breaks. After a Deal is transferred to a new rep, the context of SDR calls is only visible in the old rep’s Contact Timeline. The AE has no full picture of negotiations on the Deal page.
The Right Solution: Aircall Webhook + Engagements API
Disable the native integration. Build a custom one using the Aircall webhook and HubSpot Engagements API v3.
Step 1 - receive the Call via Aircall webhook:
from flask import Flask, request, abort
import requests, hashlib, hmac
app = Flask(__name__)
AIRCALL_API_TOKEN = "your_aircall_api_token" # Basic Auth: api_id:api_token
HUBSPOT_TOKEN = "your_hubspot_private_app_token"
HS_BASE = "https://api.hubapi.com"
@app.route("/aircall/webhook", methods=["POST"])
def aircall_webhook():
# Aircall verifies via Basic Auth on your server side
# Or via Aircall Webhook Secret (HMAC-SHA256)
data = request.json
event = data.get("event", "")
if event != "call.ended":
return "ok", 200
call = data.get("data", {})
process_aircall_call(call)
return "ok", 200
Step 2 - find the Deal in HubSpot by the contact’s phone number:
def find_hubspot_deal_by_phone(phone: str) -> dict | None:
"""Find active deal associated with contact having this phone number."""
hs = requests.Session()
hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})
# Find contact by phone
r = hs.post(f"{HS_BASE}/crm/v3/objects/contacts/search", json={
"filterGroups": [{"filters": [
{"propertyName": "phone", "operator": "EQ", "value": phone}
]}],
"properties": ["firstname", "lastname", "phone"],
"limit": 1,
})
contacts = r.json().get("results", [])
if not contacts:
return None
contact_id = contacts[0]["id"]
# Find the contact's active deals
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 active deal (not closed won/lost)
for deal_id in deal_ids:
r3 = hs.get(f"{HS_BASE}/crm/v3/objects/deals/{deal_id}",
params={"properties": "dealstage,dealname,closedate"})
deal = r3.json()
stage = deal.get("properties", {}).get("dealstage", "")
if stage not in ("closedwon", "closedlost"):
return {"id": deal_id, "contact_id": contact_id, **deal}
return None
Step 3 - create a Call Engagement on the Deal via Engagements API v3:
def create_call_on_deal(deal_id: str, contact_id: str, call: dict):
"""Create Call engagement associated with Deal (not just Contact)."""
hs = requests.Session()
hs.headers.update({"Authorization": f"Bearer {HUBSPOT_TOKEN}", "Content-Type": "application/json"})
duration_ms = call.get("duration", 0) * 1000
recording_url = call.get("recording", "")
direction = "INBOUND" if call.get("direction") == "inbound" else "OUTBOUND"
phone = call.get("from", {}).get("phone_number", "")
# Create a Call object in HubSpot
payload = {
"properties": {
"hs_call_direction": direction,
"hs_call_duration": str(duration_ms),
"hs_call_from_number": phone,
"hs_call_recording_url": recording_url,
"hs_call_status": "COMPLETED",
"hs_call_title": f"Aircall: {call.get('from', {}).get('name', '')}",
"hs_call_body": call.get("comments", ""),
"hs_timestamp": str(call.get("started_at", 0) * 1000),
}
}
r = hs.post(f"{HS_BASE}/crm/v3/objects/calls", json=payload)
r.raise_for_status()
call_id = r.json()["id"]
# Associate call with Deal
hs.put(
f"{HS_BASE}/crm/v4/objects/calls/{call_id}/associations/deals/{deal_id}",
json=[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 27}],
)
# Also associate with Contact (for complete picture)
hs.put(
f"{HS_BASE}/crm/v4/objects/calls/{call_id}/associations/contacts/{contact_id}",
json=[{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 194}],
)
return call_id
def process_aircall_call(call: dict):
phone = call.get("to", {}).get("phone_number", "") or call.get("from", {}).get("phone_number", "")
deal = find_hubspot_deal_by_phone(phone)
if not deal:
return # no active deal - log only on Contact via standard path
create_call_on_deal(deal["id"], deal["contact_id"], call)
associationTypeId: 27 is the standard type for Call -> Deal in HubSpot. 194 is for Call -> Contact.
Adding the Aircall AI Transcript
Aircall Intelligence (an add-on module) generates a call transcript. It is available via GET /v1/calls/{id} 5-10 minutes after the call ends.
import time
def get_aircall_transcript(call_id: str) -> str | None:
"""Fetch AI transcript from Aircall. Available ~10 min after call ends."""
import base64
auth = base64.b64encode(f"{AIRCALL_API_ID}:{AIRCALL_API_TOKEN}".encode()).decode()
headers = {"Authorization": f"Basic {auth}"}
for attempt in range(6): # poll up to 5 minutes
r = requests.get(f"https://api.aircall.io/v1/calls/{call_id}", headers=headers)
call_data = r.json().get("call", {})
transcript = call_data.get("transcription", {}).get("transcript", "")
if transcript:
return transcript
time.sleep(50) # wait 50 seconds between attempts
return None
The transcript is added to hs_call_body by updating the Call engagement via PATCH /crm/v3/objects/calls/{id}.
Real Case
A B2B SaaS company with a team of 8 reps and 500+ calls per month. After enabling the native integration they discovered: no calls appear on the deal page. All 500 calls for the month were only visible in the Contact Timeline.
After the custom integration:
- Every call is linked to the specific Deal
- Call history is available directly on the deal page
- AI transcripts update 10 minutes after each call
- 0 lost context when a deal is handed off between reps
Who This Affects
Any team on HubSpot + Aircall with more than one active deal per contact. If you have 1 deal per contact and no handoffs between reps, the native integration is acceptable.
The same problem exists for HubSpot + Gong and HubSpot + Salesloft.
Frequently Asked Questions
Does this work with Aircall on HubSpot Enterprise?
Yes. HubSpot Enterprise unlocks additional association types and custom engagement types, but the core logic is identical. Engagements API v3 works on all tiers including Starter.
How do you handle calls when the contact is not found in HubSpot?
If find_hubspot_deal_by_phone returns None - create the Contact in HubSpot automatically via POST /crm/v3/objects/contacts and log the call on the Contact without a Deal. Or send a notification to the rep via Slack/email for manual handling.
Can you use the native integration and the custom one simultaneously?
No - you will get duplicates. The native integration must be disabled in the Aircall Dashboard -> Integrations -> HubSpot -> Disconnect. Only then enable the custom one via webhook.
How do you route calls when a contact has multiple active deals?
In our example the first active deal is taken. A more precise solution: add a Custom Field or Tag in Aircall with the Deal ID. Before the call the rep selects the deal, this field is passed in the webhook and eliminates the ambiguity.
Summary
The native HubSpot + Aircall integration creates a structural gap: calls on Contact, context on Deal. The solution:
- Disable the native integration
- Aircall webhook
call.ended-> find the active Deal by phone number - Create a Call engagement via
POST /crm/v3/objects/calls - Associate with Deal via Associations API v4 (
associationTypeId: 27) - Add the AI transcript via PATCH after ~10 minutes
If you work with HubSpot + Aircall and need proper call-to-deal linking - describe your requirements to the Exceltic.dev team.