> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbitforms.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrate from HubSpot

> Move companies, contacts, deals and every record's history (notes, emails, calls, meetings, tasks and files) from HubSpot into Orbit with an agent, keeping dates, authors and ids.

HubSpot keeps a record's history as *engagements*: notes, emails, calls, meetings and tasks, each with a timestamp, an owner and the records it is associated with, plus attachments in its Files tool. Orbit keeps the same things: notes and tasks, logged activities for emails, calls, meetings and messages, and files on records. This page maps one to the other and gives the agent a plan it can follow end to end.

**1. Connect both sides**

Connect Orbit to your assistant ([how](https://docs.orbitforms.ai/mcp/overview.md)) and approve `crm:write`, `contacts:write`, `companies:write`, `deals:write`, `tasks:write`, `notes:write`, `activities:write` and `files:write`. Then connect [HubSpot's MCP server](https://developers.hubspot.com/mcp) next to it (it reads contacts, companies, deals and engagements), or export from HubSpot and attach the files.

**2. Invite your team**

HubSpot owners become Orbit members by matching email. Invite everyone first; whoever is not a member keeps their name on the records they wrote.

**3. Set up the structure**

Pipelines, stages and custom fields come first ([Build your CRM](https://docs.orbitforms.ai/mcp/build-your-crm.md#set-up-the-structure)). Ask the agent to show the mapping before it writes anything.

**4. Run the migration**

Records first, history second, files last, in the prompt below. Every row carries its HubSpot id, so the agent can stop and resume, and a second run updates instead of duplicating.

## The prompt

> "Migrate our HubSpot portal into Orbit. Use `hubspot` as the external\_source everywhere and pass each HubSpot object id as the external\_id.
>
> 1. Companies: `create_company` for each HubSpot company with its name, domain, industry, size, owner (match by email to our members) and `createdate` as `created_at`.
> 2. Contacts: `import_contacts` in batches of 500 with email, names, phone, job title, company, owner, `createdate` as `created_at`, and the HubSpot id as `external_id`.
> 3. Deals: `create_deal` for each deal in the matching pipeline and stage, with amount, close date, owner and `createdate`; then `add_deal_contact` for its associated contacts (use `contact_external_id`).
> 4. History: `import_activities` in batches of 500, one row per engagement, attached with `contact_external_id` / `company_external_id` / `deal_external_id`. Notes become `note`, calls `call`, meetings `meeting`, emails `email`, tasks `task`. Keep `hs_timestamp` as the time and the owner's email as `actor_email`.
> 5. Files: for every attachment, get its signed download URL from HubSpot and call `import_file_from_url` with the same record links and the HubSpot file id as `external_id`.
>
> After each step, compare counts with HubSpot and list anything that did not import with the reason."

## Field mapping

The agent needs this when it reads engagements through HubSpot's API or MCP server. Timestamps from HubSpot are Unix milliseconds; Orbit accepts them as they are or as ISO 8601.

### Every engagement

| HubSpot | Orbit | Notes |
| - | - | - |
| object id | `external_id` (with `external_source: "hubspot"`) | Makes re-runs idempotent and lets `undo_activity_import` remove a run |
| `hs_timestamp` | `occurred_at` (`created_at` for notes and tasks) | Its place on the timeline |
| `hubspot_owner_id` → owner email | `actor_email` (`author_name` on notes and tasks for non-members) | Matched to a member by email; otherwise kept as text |
| associations → contact / company / deal ids | `contact_external_id`, `company_external_id`, `deal_external_id` | Resolved through the ids stored in step 1 to 3 |
| `hs_attachment_ids` | `import_file_from_url` with `activity_id` or `note_id` | After the row is imported, using HubSpot's signed URL for each file |

### Notes

| HubSpot | Orbit |
| - | - |
| `hs_note_body` (HTML, up to 65,536 characters) | `body` (HTML is flattened to text) |

### Emails

| HubSpot | Orbit |
| - | - |
| `hs_email_direction` `EMAIL` / `INCOMING_EMAIL` / `FORWARDED_EMAIL` | `direction` `outbound` / `inbound` / `inbound` |
| `hs_email_subject` | `title` |
| `hs_email_html`, `hs_email_text` | `body_html` (sanitized), `body` |
| `hs_email_status` `SENT` / `BOUNCED` / `FAILED` / `SCHEDULED` / `SENDING` | `status` `sent` / `bounced` / `failed` / `sent` / `sent` (use `replied` or `received` when you know) |
| `hs_email_headers` `from`, `to`, `cc`, `bcc` (`email`, `firstName`, `lastName`) | `participants` `from`, `to`, `cc`, `bcc` (`email`, `name`) |

### Calls

| HubSpot | Orbit |
| - | - |
| `hs_call_direction` `INBOUND` / `OUTBOUND` | `direction` |
| `hs_call_title`, `hs_call_body` | `title`, `body` |
| `hs_call_duration` (milliseconds) | `duration_seconds` (divide by 1,000) |
| `hs_call_disposition` Connected / No answer / Busy / Left voicemail / Wrong number | `outcome` `connected` / `no_answer` / `busy` / `voicemail_left` / `wrong_number` |
| `hs_call_from_number`, `hs_call_to_number` | `participants.from_number`, `participants.to_number` |
| `hs_call_recording_url` | `external_url` (or fetch the recording with `import_file_from_url`) |

### Meetings

| HubSpot | Orbit |
| - | - |
| `hs_meeting_title`, `hs_meeting_body` | `title`, `body` |
| `hs_internal_meeting_notes` | append to `body` |
| `hs_meeting_start_time`, `hs_meeting_end_time` | `occurred_at`, `ended_at` |
| `hs_meeting_outcome` `SCHEDULED` / `COMPLETED` / `RESCHEDULED` / `NO_SHOW` / `CANCELED` | `outcome` (same values; `cancelled` is accepted either way) |
| `hs_meeting_location` | `location` |
| `hs_meeting_external_url` | `external_url` |
| attendee associations | `participants.attendees` |

### Tasks

| HubSpot | Orbit |
| - | - |
| `hs_task_subject`, `hs_task_body` | `title`, `description` |
| `hs_timestamp` (due) | `due_at` |
| `hs_task_status` `COMPLETED` | `status: "done"` with `completed_at` |
| `hs_task_priority` `LOW` / `MEDIUM` / `HIGH` | `priority` `low` / `normal` / `high` |
| owner | `assignee_email` (a non-member leaves the task unassigned) |

### Communications (SMS, WhatsApp, LinkedIn)

| HubSpot | Orbit |
| - | - |
| `hs_communication_channel_type` `SMS` | `type: "sms"` |
| any other channel | `type: "message"` |
| `hs_communication_body` | `body` |

## Large portals

A chat assistant handles a few thousand engagements; a portal with a hundred thousand emails and calls is a script's job. `scripts/maintenance/migrate-from-hubspot.ts` in the Orbit repository does exactly the plan above against the [CRM API](https://docs.orbitforms.ai/developers/api/crm.md) with a HubSpot private app token and an Orbit API key (the **CRM agent** preset), in batches, and is safe to re-run. Point a coding agent at it, or run it yourself:

```bash theme={null}
HUBSPOT_TOKEN=pat-... ORBIT_API_KEY=sk_... npx tsx scripts/maintenance/migrate-from-hubspot.ts --since 2023-01-01
```

## Check the result

Open a few contacts in Orbit: the timeline shows the migrated emails, calls, meetings and notes at the dates they happened, each marked with who did it and that it came from HubSpot. `get_timeline` reads the same feed for the agent. If a run went wrong, `undo_activity_import` removes everything that run created, records included only if they were created by that run (contacts stay; use the contact import's own undo for those).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.