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.
The mapping
Section titled “The mapping”| Email field | HubSpot object | Field |
|---|---|---|
| Sender address | contacts | email (upsert key) |
| Sender name | contacts | firstname / lastname |
| Sender domain | companies | auto-created by HubSpot |
| Subject | emails (engagement) | hs_email_subject |
| Date | emails (engagement) | hs_timestamp |
| Body snippet | emails (engagement) | hs_email_text |
API endpoints you’ll use
Section titled “API endpoints you’ll use”| Endpoint | Purpose | Batch size |
|---|---|---|
POST /crm/v3/objects/contacts/batch/create | Bulk contact create | up to 100 |
POST /crm/v3/objects/contacts/batch/upsert | Bulk contact upsert | up to 100 |
POST /crm/v3/objects/emails/batch/create | Bulk email engagement create | up to 100 |
PUT /crm/v4/associations/contact/email/batch/create | Associate engagements with contacts | up to 100 |
Always use the batch endpoints. Per-record endpoints exist but the rate-limit math gets ugly fast.
Rate limits
Section titled “Rate limits”| Plan | Per 10s | Per day |
|---|---|---|
| Free / Starter | 100 | 250,000 |
| Professional | 200 | 500,000 |
| Enterprise | 200 | 1,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.
Filter the noise
Section titled “Filter the noise”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 FREEMAILDrop 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.
Push contacts
Section titled “Push contacts”from hubspot import HubSpotfrom 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 tierPush email engagements
Section titled “Push email engagements”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.
Things to know
Section titled “Things to know”- Auto-company is opinionated. HubSpot derives the Company name from the domain, not from your data. If
acme.testis owned by Acme Corp, the Company will be named “acme.test” until something edits it. For polished records, push Companies first viacompaniesAPI with a sensiblenameanddomain, then HubSpot links Contacts to the existing record instead of creating a new one. - Emails endpoint vs Engagements API. HubSpot’s older
engagementsAPI still works but is deprecated. Usecrm/v3/objects/emails. - Idempotency.
contacts/batch/upsertupserts byemailand handles re-runs cleanly.