Skip to content
Skip to main content

Export email data to HubSpot

HubSpot has one big advantage over Salesforce for email sync: when you create a Contact with a business email, HubSpot automatically derives a Company record from the domain and links them. You only have to push Contacts; the Company side comes for free. This recipe covers the contact + engagement pattern, batch sizing, and the rate-limit math.

Filter freemail aggressively (gmail.com, yahoo.com, etc.) — otherwise auto-company creates a “Gmail Inc” record and you’ll spend Saturday morning cleaning it up.

Email fieldHubSpot objectField
Sender addresscontactsemail (upsert key)
Sender namecontactsfirstname / lastname
Sender domaincompaniesauto-created by HubSpot
Subjectemails (engagement)hs_email_subject
Dateemails (engagement)hs_timestamp
Body snippetemails (engagement)hs_email_text
EndpointPurposeBatch size
POST /crm/v3/objects/contacts/batch/createBulk contact createup to 100
POST /crm/v3/objects/contacts/batch/upsertBulk contact upsertup to 100
POST /crm/v3/objects/emails/batch/createBulk email engagement createup to 100
PUT /crm/v4/associations/contact/email/batch/createAssociate engagements with contactsup to 100

Always use the batch endpoints. Per-record endpoints exist but the rate-limit math gets ugly fast.

PlanPer 10sPer day
Free / Starter100250,000
Professional200500,000
Enterprise2001,000,000

For a 5,000-contact weekly sync via batch endpoints (50 calls), you’re well under any tier’s limit. Backfills of tens of thousands need pacing — time.sleep(0.1) between batches keeps you off the throttle.

FREEMAIL = {
"gmail.com", "yahoo.com", "hotmail.com", "outlook.com",
"icloud.com", "protonmail.com", "aol.com",
}
def is_business(email: str) -> bool:
domain = email.split("@")[1].lower()
return domain not in FREEMAIL

Drop everything that isn’t business mail. Auto-company creation makes the cost of false negatives high — once “Gmail Inc” is in the workspace, removing it is manual.

from hubspot import HubSpot
from hubspot.crm.contacts import (
BatchInputSimplePublicObjectInputForCreate,
SimplePublicObjectInputForCreate,
)
client = HubSpot(access_token=HUBSPOT_TOKEN)
def push_contacts(senders, batch_size=100):
senders = [s for s in senders if is_business(s["email"])]
for i in range(0, len(senders), batch_size):
chunk = senders[i:i+batch_size]
body = BatchInputSimplePublicObjectInputForCreate(
inputs=[
SimplePublicObjectInputForCreate(properties={
"email": s["email"],
"firstname": s["first_name"],
"lastname": s["last_name"],
"jobtitle": s.get("title", ""),
"phone": s.get("phone", ""),
})
for s in chunk
]
)
client.crm.contacts.batch_api.create(body)
time.sleep(0.1) # 10 batches/sec is well under any plan tier
def push_engagements(emails):
body = {
"inputs": [{
"properties": {
"hs_timestamp": int(parse_dt(e["date"]).timestamp() * 1000),
"hs_email_subject": e["subject"],
"hs_email_text": e["snippet"][:32000],
"hs_email_direction": "INCOMING_EMAIL"
if e["direction"] == "received"
else "EMAIL",
},
"associations": [{
"to": {"id": e["contact_id"]},
"types": [{
"associationCategory": "HUBSPOT_DEFINED",
"associationTypeId": 198, # Contact ↔ Email
}]
}]
} for e in emails]
}
requests.post(
"https://api.hubapi.com/crm/v3/objects/emails/batch/create",
headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
json=body,
).raise_for_status()

Build engagements after contacts so the to.id references resolve. The cheapest pattern: push contacts in pass 1, query back to get IDs by email, then push engagements in pass 2.

  • Auto-company is opinionated. HubSpot derives the Company name from the domain, not from your data. If acme.test is owned by Acme Corp, the Company will be named “acme.test” until something edits it. For polished records, push Companies first via companies API with a sensible name and domain, then HubSpot links Contacts to the existing record instead of creating a new one.
  • Emails endpoint vs Engagements API. HubSpot’s older engagements API still works but is deprecated. Use crm/v3/objects/emails.
  • Idempotency. contacts/batch/upsert upserts by email and handles re-runs cleanly.