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 any email provider using Nylas CLI and pushes it into HubSpot via batch API endpoints, the association API, and timeline events.

Written by Prem Keshari Senior SRE

Reviewed by Nick Barraclough

VerifiedCLI 3.1.1 · Gmail, Outlook · last tested April 11, 2026

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 and filter for HubSpot

Exporting email data for HubSpot requires filtering out freemail domains before import because HubSpot auto-creates Company records from every email domain it encounters. Without filtering, addresses like user@gmail.com generate a junk Company called “gmail.com” in your CRM. Stripping the top 4 freemail providers (Gmail, Yahoo, Outlook, Hotmail) typically removes 40-60% of sender addresses from a mixed inbox.

The Nylas CLI exports email and contact data as JSON from any connected provider. The --limit 500 flag caps the export to 500 records per call, which fits comfortably within HubSpot's Free-tier daily limit of 250,000 API calls. After exporting, a jq filter strips freemail domains so only business contacts remain.

# Pull email history and contacts into local JSON
nylas email list --json --limit 500 > emails.json
nylas contacts list --json --limit 500 > contacts.json

# HubSpot-specific: strip freemail domains to prevent junk Company records
# HubSpot auto-creates Companies from email domains, so gmail.com creates
# a Company called "gmail.com" -- filter these out before import
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 enforces different API rate limits depending on your subscription tier. Free and Starter plans allow 100 API calls per 10 seconds and 250,000 calls per day, while Enterprise plans allow 200 calls per 10 seconds and 500,000 per day. These limits determine how fast you can batch-import email data into HubSpot without hitting 429 throttling errors.

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 accept up to 100 records per request, reducing a 500-contact import from 500 individual API calls to just 5 batch calls. The batch endpoints support three operations: create, update, and upsert (upsert requires a Professional or Enterprise plan). Each batch request counts as a single API call against your rate limit, making batch the most efficient way to sync email data at scale.

The four key batch endpoints for email import workflows are listed here. All accept the same JSON structure with an inputs array containing up to 100 objects.

  • 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

The script below transforms a Nylas CLI JSON export into a HubSpot batch payload using jq. It deduplicates senders by email address, caps each batch at 100 records, and splits the name field into firstname and lastname properties. According to HubSpot's API documentation, the batch create endpoint returns a 201 status with all created records on success, or a 207 multi-status when some records fail (for example, if a contact with that email already exists).

# 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

HubSpot's Timeline Events API lets you attach custom activity entries to any CRM record, going beyond the standard email engagement model. Timeline events support up to 40 custom tokens (fields) per event template, which means you can log metadata like thread length, response time in minutes, CC count, and attachment size that HubSpot's built-in email logging ignores entirely.

Before posting events, create a timeline event template inside your HubSpot private app under Settings → Integrations → Private Apps → Timeline Events. Each template defines the tokens your events will contain, and HubSpot validates incoming events against the template schema. The Timeline Events API accepts up to 10 events per request on Free-tier plans.

# 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 links CRM objects together using numeric type IDs that define the relationship between them. Every email import workflow needs at least 3 association types: Contact-to-Company (type 1), Contact-to-Deal (type 3), and Email-to-Contact (type 198). Without these associations, imported data sits in isolated silos, and HubSpot workflows can't trigger on cross-object conditions like “contact on a $50K deal sent an email.”

FromToType IDLabel
ContactCompany1Primary company
ContactDeal3Contact to deal
EmailContact198Email to contact
CompanyDeal5Company to deal

The Association API v4 uses PUT requests for single associations and POST requests for batch associations. Each association call requires the associationCategory (always HUBSPOT_DEFINED for built-in types) and the numeric associationTypeId from the table above. Batch association requests accept up to 100 association pairs per call, so linking 200 contacts to a single deal takes just 2 API calls.

# 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

HubSpot Workflows, available on Professional and Enterprise plans, can automatically move deals through pipeline stages based on email engagement properties that update when you log emails via the API. For example, a workflow can advance a deal from “Contacted” to “Engaged” when hs_email_last_reply_date changes, or create a follow-up task when no reply arrives within 72 hours of logging an engagement.

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)

According to HubSpot's documentation, Professional plans support up to 300 workflows and Enterprise plans support up to 500 workflows. Each workflow can include up to 256 actions, which is enough to chain email-based triggers with Slack notifications, deal stage changes, and task creation in a single automation.

Python sync with hubspot-api-client

The hubspot-api-client Python library wraps HubSpot's API v3 with typed models and built-in retry logic, handling the batch payload structure and 429 rate-limit retries automatically. The script below runs the full sync pipeline: export up to 500 emails from any provider via Nylas CLI, deduplicate senders, filter out 7 freemail domains, batch-create contacts in groups of 100, and log email engagements with Contact associations using type ID 198.

#!/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.")

The hubspot-api-client package requires Python 3.7 or later and installs with pip. Generate a private app token in HubSpot under Settings → Integrations → Private Apps with the crm.objects.contacts.write and crm.objects.emails.write scopes. The token format starts with pat- followed by a region prefix like na1 or eu1.

pip install hubspot-api-client

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

python3 hubspot_sync.py

Next steps

After importing email data into HubSpot, the next priorities are enriching contact records with metadata extracted from email signatures, building deal pipelines from email engagement patterns, and syncing to other CRMs if your team uses more than one. The guides below cover 8 related workflows across Salesforce, contact enrichment, company grouping, and contact hierarchy analysis.