Guide

Export Email Data to HubSpot

HubSpot auto-creates companies from email domains, triggers workflows on engagement properties, and deduplicates by email address. This guide exports data from Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP using Nylas CLI and pushes it into HubSpot via batch API endpoints, the association API, and timeline events.

By Prem Keshari

HubSpot's native email integration vs. CLI sync

HubSpot offers built-in email integration for Gmail and Outlook. You connect your inbox in Settings → General → Email, and HubSpot logs emails automatically. It works, but it has three limitations that matter for engineering teams.

First, it only supports Gmail and Outlook. If your team uses Exchange on-prem, Yahoo, iCloud, or custom IMAP servers, HubSpot's native integration doesn't cover them. According to HubSpot's documentation, “email logging requires a connected Gmail or Office 365 email account.”

Second, it logs emails from individual inboxes. There's no way to bulk-import historical email data. If you have 2 years of email history with 500 contacts, you can't retroactively log those interactions. A CLI-based approach can process your entire email archive in one run.

Third, you can't customize the mapping. HubSpot decides which fields get populated. With a CLI export, you control exactly which properties get set, which custom fields get populated, and which records get created vs. updated.

Export from Nylas CLI

Pull email and contact data into local JSON files. Before pushing to HubSpot, filter out freemail domains (gmail.com, yahoo.com) to avoid creating junk Company records from auto-association.

# Export recent emails and contacts as JSON
nylas email list --json --limit 500 > emails.json
nylas contacts list --json --limit 500 > contacts.json

# Filter out freemail senders that would create junk Companies
cat emails.json | jq '[.[] | select(
  .from[0].email | split("@")[1] |
  IN("gmail.com","yahoo.com","outlook.com","hotmail.com") | not
)]' > emails-business.json

Automatic company creation from domains

HubSpot has a feature most CRMs lack: when you create a Contact with an email address, HubSpot automatically creates a Company from the email domain and links the two. Create a contact with sarah@acme.com, and HubSpot creates a Company with domain: acme.com and associates them.

This is controlled by the setting “Create and associate companies with contacts” under Settings → Objects → Companies. It's on by default. HubSpot also enriches the Company record with data from its own database: employee count, revenue, industry, and address. According to HubSpot, their company database covers over 20 million businesses.

This means your import strategy can be simpler. You don't need to create Companies explicitly. Just batch-create Contacts with email addresses, and HubSpot handles the rest. But there are gotchas:

  • Free email domains (gmail.com, yahoo.com) create junk Company records. Filter them before import.
  • Auto-created Companies use the domain as the name (e.g., “acme.com”). If you want “Acme Corp”, create the Company first with an explicit name.
  • The auto-association uses association type 1 (primary company). A Contact can have multiple Company associations, but only one primary.

Free vs. paid tier API limits

HubSpot's API limits vary significantly by plan. This matters when you're importing thousands of records.

PlanPer 10 secondsPer dayBatch size
Free / Starter100 calls250,000 calls100 records
Professional150 calls500,000 calls100 records
Enterprise200 calls500,000 calls100 records

The batch endpoints accept up to 100 records per request. Importing 500 contacts takes 5 API calls instead of 500. Always use batch endpoints when importing more than a handful of records.

Batch create and update via API v3

HubSpot's batch API v3 endpoints let you create or update up to 100 records in one request. The key endpoints:

  • POST /crm/v3/objects/contacts/batch/create — create up to 100 contacts
  • POST /crm/v3/objects/contacts/batch/update — update up to 100 contacts by ID
  • POST /crm/v3/objects/contacts/batch/upsert — create or update by email (requires Pro+)
  • POST /crm/v3/objects/emails/batch/create — log up to 100 email engagements
# Batch create contacts from Nylas CLI export
# Transform emails.json senders into HubSpot batch payload
cat emails.json | jq '{
  inputs: [
    [.[] | .from[0] | select(.email != null)]
    | unique_by(.email)
    | .[:100][]
    | {
        properties: {
          email: .email,
          firstname: (.name // "" | split(" ")[0]),
          lastname: (.name // "" | split(" ")[1:] | join(" "))
        }
      }
  ]
}' > /tmp/hubspot-batch.json

curl -s -X POST "https://api.hubapi.com/crm/v3/objects/contacts/batch/create" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d @/tmp/hubspot-batch.json | jq '{
    status: .status,
    created: (.results | length),
    errors: (.errors // [] | length)
  }'

Timeline events API

Beyond standard email engagements, HubSpot's Timeline Events API lets you create custom timeline entries on any CRM record. This is useful when you want to log email metadata that doesn't fit into the standard engagement model.

First, create a timeline event template in your HubSpot app (Settings → Integrations → Private Apps → Timeline Events). Define the tokens (fields) your events will contain. Then POST events:

# Create a custom timeline event on a Contact
curl -s -X POST "https://api.hubapi.com/crm/v3/timeline/events" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "eventTemplateId": "123456",
    "objectId": "901",
    "timestamp": "2026-03-12T14:30:00Z",
    "tokens": {
      "emailSubject": "Re: Q3 Partnership Proposal",
      "senderDomain": "acme.com",
      "threadLength": "7",
      "responseTimeMinutes": "23",
      "ccCount": "3"
    }
  }'

Timeline events appear in the Contact's activity feed alongside calls, meetings, and standard emails. The custom tokens let you track metrics HubSpot doesn't capture natively: thread length, CC count, response time, attachment count, etc.

Association API deep dive

HubSpot's Association API v4 connects objects to each other. The association type ID determines the relationship. The common ones for email import:

FromToType IDLabel
ContactCompany1Primary company
ContactDeal3Contact to deal
EmailContact198Email to contact
CompanyDeal5Company to deal
# Associate a Contact with a Company (type 1)
curl -s -X PUT \
  "https://api.hubapi.com/crm/v4/objects/contacts/$CONTACT_ID/associations/companies/$COMPANY_ID" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[{
    "associationCategory": "HUBSPOT_DEFINED",
    "associationTypeId": 1
  }]'

# Associate an email engagement with a Contact (type 198)
curl -s -X PUT \
  "https://api.hubapi.com/crm/v4/objects/emails/$EMAIL_ID/associations/contacts/$CONTACT_ID" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '[{
    "associationCategory": "HUBSPOT_DEFINED",
    "associationTypeId": 198
  }]'

# Batch associate — link multiple contacts to a deal at once
curl -s -X POST \
  "https://api.hubapi.com/crm/v4/associations/contacts/deals/batch/create" \
  -H "Authorization: Bearer $HUBSPOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "inputs": [
      {
        "from": {"id": "101"},
        "to": {"id": "201"},
        "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 3}]
      },
      {
        "from": {"id": "102"},
        "to": {"id": "201"},
        "types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 3}]
      }
    ]
  }'

Deal pipeline automation from email signals

Once email data flows into HubSpot, you can build workflow automations that move deals through your pipeline based on email engagement. HubSpot Workflows (available on Professional and Enterprise plans) trigger on contact or deal property changes.

Properties that update when you log email engagements via the API:

  • hs_email_last_email_date — timestamp of the most recent email
  • hs_email_last_reply_date — when the contact last replied
  • num_associated_deals — count of deals linked to the contact
  • hs_sales_email_last_replied — last reply to a sales email specifically
  • notes_last_updated — timestamp of the most recent note or activity

Example workflows you can build:

  • Move deal to “Engaged” stage when a contact on the deal replies to an email for the first time in 14+ days
  • Create a follow-up task when an email engagement is logged but no reply comes within 3 days
  • Notify the deal owner in Slack when a contact associated with a deal worth $50K+ sends an email
  • Update lead score by +10 points each time an email engagement is created (HubSpot's lead scoring uses contact properties)

Python sync with hubspot-api-client

This script handles the full flow: export from Nylas CLI, batch-create contacts (letting HubSpot auto-create companies), log email engagements with associations, and respect rate limits with built-in retry.

#!/usr/bin/env python3
"""Sync Nylas CLI email data to HubSpot CRM via batch API v3."""

import json
import subprocess
import os
import sys
import time

from hubspot import HubSpot
from hubspot.crm.contacts import BatchInputSimplePublicObjectInputForCreate

FREEMAIL = {"gmail.com", "yahoo.com", "outlook.com", "hotmail.com",
            "aol.com", "icloud.com", "protonmail.com"}

token = os.environ.get("HUBSPOT_TOKEN")
if not token:
    print("Set HUBSPOT_TOKEN env var", file=sys.stderr)
    sys.exit(1)

api = HubSpot(access_token=token)

# Export from Nylas CLI
emails = json.loads(subprocess.run(
    ["nylas", "email", "list", "--json", "--limit", "500"],
    capture_output=True, text=True, check=True,
).stdout)

print(f"Exported {len(emails)} emails")

# Deduplicate senders, filter freemail
seen: set[str] = set()
contacts_to_create: list[dict] = []
for msg in emails:
    sender = (msg.get("from") or [{}])[0]
    addr = sender.get("email", "")
    if not addr or addr in seen:
        continue
    domain = addr.split("@")[1] if "@" in addr else ""
    if domain in FREEMAIL:
        continue
    seen.add(addr)
    name = sender.get("name", "")
    parts = name.split(" ", 1) if name else ["", ""]
    contacts_to_create.append({
        "email": addr,
        "firstname": parts[0],
        "lastname": parts[1] if len(parts) > 1 else "",
    })

print(f"{len(contacts_to_create)} unique business contacts to sync")

# Batch create contacts (100 at a time)
# HubSpot auto-creates Company records from email domains
contact_id_map: dict[str, str] = {}

for i in range(0, len(contacts_to_create), 100):
    batch = contacts_to_create[i:i+100]
    try:
        result = api.crm.contacts.batch_api.create(
            batch_input_simple_public_object_input_for_create=
            BatchInputSimplePublicObjectInputForCreate(
                inputs=[{"properties": c} for c in batch]
            )
        )
        for r in result.results:
            contact_id_map[r.properties["email"]] = r.id
        print(f"  Batch {i//100+1}: {len(result.results)} created")
    except Exception as e:
        # 409 = contact exists — search and get ID
        print(f"  Batch {i//100+1} partial: {e}")
        for c in batch:
            try:
                search = api.crm.contacts.search_api.do_search({
                    "filter_groups": [{"filters": [{
                        "property_name": "email",
                        "operator": "EQ",
                        "value": c["email"],
                    }]}],
                    "limit": 1,
                })
                if search.total > 0:
                    contact_id_map[c["email"]] = search.results[0].id
            except Exception:
                pass

    # Respect rate limits: 100 calls / 10 sec
    time.sleep(0.15)

print(f"Mapped {len(contact_id_map)} contacts")

# Log email engagements with Contact associations
engagement_count = 0
for msg in emails[:200]:
    sender = (msg.get("from") or [{}])[0]
    addr = sender.get("email", "")
    contact_id = contact_id_map.get(addr)
    if not contact_id:
        continue

    ts = msg.get("date", "")
    if isinstance(ts, int):
        from datetime import datetime, timezone
        ts = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()

    try:
        api.crm.objects.emails.basic_api.create(
            simple_public_object_input_for_create={
                "properties": {
                    "hs_timestamp": ts,
                    "hs_email_direction": "INCOMING_EMAIL",
                    "hs_email_subject": msg.get("subject", ""),
                    "hs_email_text": msg.get("snippet", "")[:2000],
                    "hs_email_status": "SENT",
                    "hs_email_sender_email": addr,
                },
                "associations": [{
                    "to": {"id": contact_id},
                    "types": [{
                        "associationCategory": "HUBSPOT_DEFINED",
                        "associationTypeId": 198,
                    }],
                }],
            }
        )
        engagement_count += 1
    except Exception as e:
        print(f"  Error logging {addr}: {e}", file=sys.stderr)

print(f"Logged {engagement_count} email engagements")
print("Sync complete.")

Install and run:

pip install hubspot-api-client

export HUBSPOT_TOKEN="pat-na1-xxxxxxxx"  # Private app token

python3 hubspot_sync.py

Next steps