# Export email data to HubSpot

Source: https://developer.nylas.com/docs/cookbook/use-cases/sync/export-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.

## 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

| 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

| 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

```python
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.

## Push contacts

```python
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
```

## Push email engagements

```python
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

- **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.

## Next steps

- [Sync email contacts to a CRM](/docs/cookbook/use-cases/sync/sync-email-crm/)
- [Export to Salesforce](/docs/cookbook/use-cases/sync/export-to-salesforce/)
- [Export to Pipedrive](/docs/cookbook/use-cases/sync/export-to-pipedrive/)