HubSpot Service Hub to Help Scout Migration: Technical Guide
Migrate HubSpot Service Hub to Help Scout without data loss. Covers API limits, object mapping, thread caps, custom field constraints, and step-by-step migration architecture.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
HubSpot Service Hub to Help Scout Migration: Technical Guide
TL;DR: Migrating from HubSpot Service Hub to Help Scout means moving from a relational CRM-native ticketing architecture to a flat, mailbox-centric conversation model. There is no native import path for conversations — you must extract via HubSpot's CRM APIs and write into Help Scout's Mailbox API 2.0 one conversation at a time. Help Scout caps conversations at 100 threads and limits custom fields to 10 per inbox (Plus plan and above only). HubSpot's pipelines, SLAs, and multi-object associations have no direct equivalent. For datasets under 10K tickets with a dedicated engineer, DIY is viable; beyond that, a managed migration service reduces risk significantly.
Why Teams Move from HubSpot Service Hub to Help Scout
HubSpot Service Hub is a CRM-native customer service platform where tickets, contacts, companies, and deals share a single relational database. Help Scout is a shared-inbox tool designed for teams that want a simple, email-first support experience without CRM overhead.
Teams typically make this move for three reasons:
- Cost. HubSpot Service Hub pricing is easy to underestimate — the headline is a clean per-seat number, but the real cost is shaped by mandatory onboarding fees, a credit-based AI system, and CRM limits that compound as you grow. Service Hub Professional starts at $90 per seat per month, billed annually, with a $1,500 one-time onboarding fee. Help Scout's paid plans range from Standard at $25/user/month to Pro at $75/user/month. For a 10-person support team, the annual difference is roughly $7,800–$10,200 before factoring in HubSpot's onboarding fee.
- Simplicity. Teams that only need shared inboxes and a knowledge base find HubSpot's CRM architecture — objects, associations, pipelines, custom properties — over-engineered for their workflow. Help Scout was built around a shared email inbox model, and the experience reflects that strength.
- Separation of concerns. Many teams want to keep HubSpot for marketing or CRM while moving frontline support into a dedicated shared inbox. Help Scout's native HubSpot app supports this split by showing HubSpot contact and company properties, latest activity, list memberships, workflows, and deals in the Help Scout sidebar. (docs.helpscout.com)
Before committing, understand the core data model differences. This is not a like-for-like swap.
Data Model Differences: HubSpot vs Help Scout
HubSpot stores CRM information as objects (like contacts, companies, deals, tickets). Each object has records (individual entries). Each record has properties (fields). And records connect to each other through associations.
Help Scout's architecture is fundamentally different. The platform centers around mailboxes containing conversations, each with threads (replies, notes, chat messages). Customers exist as a separate entity linked to conversations, and organizations loosely group customers. There are no pipelines, no deals, no multi-object association graphs.
The core engineering challenge is flattening HubSpot's relational graph into Help Scout's linear conversation model without losing historical context, author attribution, or original timestamps.
| HubSpot Service Hub | Help Scout | Notes |
|---|---|---|
| Tickets | Conversations | Tickets become conversations in a target mailbox |
| Ticket pipeline / stages | Status (active, pending, closed) | No pipeline equivalent; status is a flat enum |
| Contacts | Customers | 1:1 mapping; match on email |
| Companies | Organizations | Basic mapping; no deep association graph |
| Engagements (emails, notes, calls) | Threads (customer, reply, note, chat, phone) | Each engagement becomes a thread |
| Custom properties (unlimited) | Custom fields (max 10 per inbox) | Major constraint — data loss risk |
| Associations (ticket↔contact↔company↔deal) | Conversation↔Customer (single link) | Multi-object relationships lost |
| Knowledge Base articles | Docs articles | Separate API (Docs API v1); manual structure rebuild |
| Workflows / Automations | Workflows | Must be rebuilt manually |
| SLAs | No native SLA engine | Lost in migration |
Critical constraint: Each inbox can have up to 10 custom fields. There are 5 supported field types: Dropdown (with a limit of 100 options), Single line, Multi-line, Number, and Date. If your HubSpot ticket object has 30+ custom properties, you will need to decide which 10 matter most per inbox, or flatten the rest into notes or tags.
Migration Approaches Compared
There are five viable methods. Each has a different complexity ceiling and failure mode.
1. Native CSV Export/Import
How it works: Export tickets and contacts from HubSpot as CSV via the CRM UI or Export API. Import customers into Help Scout manually or via their customer import flow.
When to use it: Only for contact/customer migration (names, emails, phone numbers). Not for tickets.
Pros: Zero engineering effort for basic customer records.
Cons: Help Scout has no native CSV import for conversations. Help Scout's native export gives you reporting CSV/XLSX only — no message bodies. HubSpot's native ticket export does not include the bodies of associated engagements either. You get metadata at best — no thread content, no attachments, no notes. This is a non-starter for ticket history.
Scalability: Small datasets only. Complexity: Low.
2. API-Based Migration (HubSpot CRM API → Help Scout Mailbox API 2.0)
How it works: Extract tickets, engagements, contacts, and companies from HubSpot using the CRM v3/v4 APIs. Transform data to match Help Scout's conversation/thread model. Create conversations and threads via the Mailbox API 2.0.
Important: HubSpot deprecated the Engagements v1 API. Use the CRM v3 objects API for emails (/crm/v3/objects/emails), notes (/crm/v3/objects/notes), and calls (/crm/v3/objects/calls). The v3 endpoints return richer property sets and support the standard association model.
When to use it: This is the primary method for any migration that requires conversation history, attachments, and thread content.
Pros: Full control over data mapping. Preserves thread content, notes, and attachments. Handles custom fields.
Cons: Help Scout's API does not support user creation via REST. POST /users is not available in the public Mailbox API v2 — new users must be provisioned through the UI invite flow or via SCIM (Pro plan only). Each conversation requires multiple API calls (create conversation, then create each thread). A single conversation can contain up to 100 threads. If you try to create a conversation with more than 100 threads, the API will return HTTP 412 Precondition failed error.
Scalability: Works at any scale with proper batching and rate-limit handling. Complexity: High.
3. Third-Party Migration Tools or Managed Services
How it works: Services or tools like Import2 (Help Scout's official import partner) connect both platforms and handle extraction, transformation, and loading through managed pipelines.
Help Scout's official import tool is partnered with Import2, which runs a sample migration first. Import2 supports organizations, customers, tickets, messages, notes, and attachments from HubSpot Service Hub, but does not migrate database configuration such as users, reports, templates, or automations. (docs.helpscout.com)
When to use it: Teams without dedicated engineering bandwidth, or accounts with 10K+ tickets where DIY risk is high.
Pros: Handles edge cases (attachments, inline images, agent matching). Faster turnaround. Managed services can complete a migration in under a day when the accounts are connected and the sample migration is approved promptly.
Cons: Cost. Less control over transformation logic for tool-based approaches.
Scalability: Enterprise-ready. Complexity: Low (for you).
4. Custom ETL Pipeline
How it works: Build a pipeline using Python/Node.js with an orchestrator (Airflow, Dagster, or a cron job). Extract from HubSpot, stage in an intermediate store (PostgreSQL, S3), transform, and load into Help Scout.
When to use it: Large migrations (50K+ tickets) where you need checkpointing, retry logic, and audit trails. Also fits regulated teams and multi-brand operations.
Pros: Full observability. Replayable. Handles deduplication and complex transformations. Better compliance controls.
Cons: 2–4 weeks of engineering time to build and test. Overkill for small accounts.
Scalability: Highest. Complexity: Very High.
5. Middleware Platforms (Zapier, Make)
How it works: Use pre-built HubSpot and Help Scout connectors to trigger record creation on events.
When to use it: Ongoing sync of new tickets only. Not suitable for historical migration.
Pros: No code required for basic workflows. Good for operational glue after cutover.
Cons: Cannot backfill historical data. Rate limits and task-execution costs make bulk migration impractical. No thread-level control. Attachment handling is fragile.
Scalability: Very limited. Complexity: Low.
Comparison Table
| Method | Historical Data | Attachments | Thread Content | Custom Fields | Complexity | Best For |
|---|---|---|---|---|---|---|
| CSV Export/Import | Metadata only | ✗ | ✗ | Partial | Low | Contacts only |
| API-to-API | ✓ | ✓ | ✓ | ✓ (max 10) | High | Full migration, eng team available |
| Migration Service/Tool | ✓ | ✓ | ✓ | ✓ | Low (managed) | Most teams |
| Custom ETL | ✓ | ✓ | ✓ | ✓ | Very High | 50K+ tickets, audit requirements |
| Middleware | New data only | Partial | Partial | ✗ | Low | Ongoing sync only |
Recommendation: For most teams migrating under 10K tickets with a dedicated engineer, the API-to-API approach works. For anything larger — or if you cannot afford a failed migration — use a managed service.
When to Use a Managed Migration Service
Build in-house when you have a dedicated engineer for 2+ weeks, your ticket count is under 10K, and you have minimal custom field complexity.
Use a managed service when:
- Your HubSpot account has complex association chains (ticket → contact → company → deal) that need to be preserved as context in Help Scout conversations
- You have attachments, inline images, or embedded files across thousands of tickets
- Engineering bandwidth is committed to product work and cannot absorb a multi-week migration project
- You need delta migration (transferring records created during the migration window) to achieve zero downtime
- Support cannot pause during the transition
Hidden costs of DIY migration are real. Rate-limit debugging, attachment encoding failures, agent-matching logic, timezone normalization, and thread-ordering bugs typically consume 3–5× the originally estimated engineering hours. Teams frequently budget one week and consume two full sprints.
At ClonePartner, we've completed 1,500+ data migrations including complex helpdesk transitions. We handle custom field compression, attachment re-encoding, agent provisioning, and the delta sync that catches records created during the migration window.
Pre-Migration Planning Checklist
Before writing any code, audit your HubSpot account and define scope.
Data Audit
- Tickets: Count total, count by pipeline/stage, count by status (open/closed). Identify tickets with 100+ engagements — these will hit Help Scout's thread cap.
- Contacts: Count total, identify duplicates by email. Note contacts with no associated tickets (decide whether to migrate).
- Companies: Count total. Decide if these map to Help Scout organizations.
- Custom properties: List every ticket custom property. Identify which 10 (per inbox) you need in Help Scout. Plan where the rest will go (notes, tags, or dropped).
- Attachments: Estimate total size. Identify any files over 10 MB (Help Scout's per-attachment limit).
- Knowledge Base: Count articles, categories, and collections. These use Help Scout's Docs API v1 — a separate API from the Mailbox API with its own endpoints for categories (
/v1/collections/{id}/categories), collections (/v1/collections), and articles (/v1/articles). - Legacy inboxes: If your HubSpot account still uses the legacy conversations inbox, note that early — older accounts can have inbox history that behaves differently from the newer Help Desk workspace. (knowledge.hubspot.com)
Define What NOT to Migrate
- Spam/junk tickets
- Test data from sandbox environments
- Tickets older than your compliance retention window
- Unused custom properties with no values
- Dead pipelines and retired tags
- Workflow configurations (these must be rebuilt in Help Scout)
Choose a Migration Strategy
| Strategy | When to Use | Risk |
|---|---|---|
| Big bang | Under 20K tickets, weekend cutover window available | Higher — all-or-nothing |
| Phased | Multiple mailboxes, can migrate one at a time | Lower — isolate failures |
| Incremental | Large datasets, need zero downtime | Lowest — requires delta sync |
For most teams, phased migration (one HubSpot pipeline → one Help Scout mailbox at a time) offers the best balance of speed and safety.
Freeze Schema Changes
Stop adding new custom properties in HubSpot during the migration window to prevent schema mismatches. Lock your field mapping before extraction begins.
Data Mapping: HubSpot Objects → Help Scout Objects
The mapping principle is straightforward: move support history into Help Scout, but keep CRM-native constructs where they belong.
Ticket → Conversation Mapping
Each HubSpot ticket becomes a Help Scout conversation. The ticket's pipeline determines the target mailbox. Ticket status maps to Help Scout's three-state model:
| HubSpot Ticket Status | Help Scout Status |
|---|---|
| New / Waiting on contact / Waiting on us | active |
| Pending (custom pipeline stage) | pending |
| Closed | closed |
Note: HubSpot's internal pipeline stage IDs are account-specific. Do not hardcode values like "1", "2", "3", "4" — fetch your pipeline's stage definitions via GET /crm/v3/pipelines/tickets and map by label rather than numeric ID.
Engagements → Threads
As a developer, you must streamline customer support processes by associating existing tickets with other CRM records or activities (i.e., calls, emails, meetings, notes, etc.). Activities are also called engagements.
Use the CRM v3 objects API (not the deprecated Engagements v1 API) to extract these. Each engagement type maps to a specific Help Scout thread type:
| HubSpot Engagement | Help Scout Thread | API Endpoint |
|---|---|---|
| Email (from customer) | Customer thread | POST /v2/conversations/{id}/customer |
| Email (from agent) | Reply thread | POST /v2/conversations/{id}/reply |
| Note | Note thread | POST /v2/conversations/{id}/notes |
| Call | Phone thread | POST /v2/conversations/{id}/phones |
| Chat message | Chat thread | POST /v2/conversations/{id}/chats |
| Meeting / Task | Note thread (with metadata) | No direct equivalent |
Contacts → Customers
Match on email address. HubSpot contacts map 1:1 to Help Scout customers. Use POST /v2/customers to create, then link via the conversation's customer field.
Companies → Organizations
HubSpot companies map to Help Scout organizations. The link between customer and organization must be set explicitly via PATCH /v2/customers/{id} after both records exist.
Deals and Custom Objects
Help Scout has no native deal pipeline or custom object layer. Most teams keep deals and custom objects in HubSpot and surface only the needed context in Help Scout via the native HubSpot app. Do not recreate a fake pipeline in Help Scout — it will not map cleanly and will confuse agents. (docs.helpscout.com)
Custom Properties → Custom Fields
Any single custom field belongs specifically to a single inbox. If you move a conversation from one inbox to another, the custom field data will not carry over even if you have custom fields in that inbox with the same name.
This is the hardest mapping decision. Steps:
- Export all HubSpot ticket custom properties (name, type, options) via
GET /crm/v3/properties/tickets - Rank by usage (how many tickets have a non-null value)
- Select the top 10 per target inbox
- For remaining properties, prepend them as structured text in a migration note on each conversation
- Custom fields are available on the Plus and Pro plans. If you are on Help Scout Standard, custom fields are not available — all custom data must go into tags or notes.
Two Help Scout details matter here. Customer and company properties are global and can hold CRM-style context across inboxes. Conversation custom fields are inbox-specific, so they are a poor place to store cross-mailbox master data. (docs.helpscout.com)
Multi-Contact Associations
HubSpot allows multiple contacts to be associated with a single ticket. Help Scout conversations belong to exactly one customer. You must designate a primary contact during transformation and add secondary contacts to the CC line of the threads or record them in an internal note.
Sample Data Mapping Table
| HubSpot Ticket Property | Help Scout Field | Type | Transformation |
|---|---|---|---|
subject |
subject |
String | Direct |
hs_pipeline |
mailboxId |
ID | Map pipeline → mailbox via lookup table |
hs_pipeline_stage |
status |
Enum | Map stage label to active/pending/closed |
hs_ticket_priority |
Tag or custom field | String | priority:{value} |
hs_ticket_category |
Tag or custom field | String | category:{value} |
hubspot_owner_id |
assignee.id |
ID | Owner → agent lookup |
createdate |
createdAt |
ISO 8601 | Direct (HubSpot stores UTC; Help Scout expects ISO 8601 with timezone) |
closed_date |
closedAt |
ISO 8601 | Direct |
content (engagement body) |
Thread text |
HTML | Sanitize tracking pixels, rewrite inline image URLs |
| Associated contact email | customer.email |
String | Direct |
| Associated company name | Organization name | String | Create org, link to customer |
Migration Architecture
The data flow follows a standard ETL pattern:
HubSpot CRM APIs → Extract → Staging DB → Transform → Help Scout Mailbox API 2.0 → Validate
Extract Phase: HubSpot APIs
Use HubSpot's CRM v3 APIs for extraction:
GET /crm/v3/objects/tickets— list all tickets with propertiesGET /crm/v4/objects/tickets/{id}/associations/{toObjectType}— get associated contacts, companies, engagementsGET /crm/v3/objects/emails/{id}— get email engagement content (v3 objects API, not deprecated Engagements v1)GET /crm/v3/objects/notes/{id}— get note contentGET /crm/v3/objects/calls/{id}— get call metadataGET /crm/v3/pipelines/tickets— get pipeline definitions and stage labels for status mapping
HubSpot private apps now allow 190 calls/10s; with the capacity pack up to 250/10s. Use the CRM Search API for filtered batch reads (up to 200 records per call).
Always include the associations parameter when reading tickets to pull linked records in a single call, reducing your rate-limit consumption. Example: GET /crm/v3/objects/tickets/{id}?associations=contacts,emails,notes
If your HubSpot account uses the legacy Conversations API for thread history, note that this API may return truncated messages. Check the truncated flag and fetch original content before transforming. (developers.hubspot.com)
Transform Phase
The transform stage handles:
- Thread ordering: Sort HubSpot engagements by
hs_timestampto reconstruct the chronological conversation. Ifhs_timestampis null (rare but possible on older records), fall back tohs_createdate. - HTML sanitization: HubSpot email bodies may contain tracking pixels (
<img>tags withtrack.hubspot.comURLs) and HubSpot-specifichs-ctadiv wrappers. Strip these before importing. Use a library likebleach(Python) orsanitize-html(Node.js) with an allowlist of safe tags. - Attachment encoding: Help Scout's Create Reply endpoint expects attachments as base64-encoded
datafields withfileNameandmimeTypeparameters. Download from HubSpot's file API, base64-encode, and embed. For attachments over 10 MB, link to an external URL in the thread body instead. - Agent matching: Map HubSpot owner IDs to Help Scout user IDs. Agents must already exist in Help Scout before import. Build this lookup table during pre-migration.
- Custom field compression: Reduce HubSpot's unlimited properties to Help Scout's 10-field-per-inbox limit. Overflow goes into notes or tags.
- Timezone normalization: HubSpot stores all timestamps in UTC (milliseconds since epoch). Help Scout expects ISO 8601 format (
2024-01-15T14:30:00Z). Convert with explicit UTC timezone designation. If you omit the timezone offset, Help Scout may interpret timestamps in the mailbox's configured timezone, breaking thread ordering. - Null and malformed data handling: Check for null email addresses on contacts (skip or assign a placeholder), empty engagement bodies (create a note thread with " [Empty message body]"), and HTML that fails to parse (fall back to plain text extraction).
Load Phase: Help Scout Mailbox API 2.0
Authentication: Help Scout uses OAuth 2.0 with short-lived access tokens (valid for 48 hours). Your migration script must implement token refresh logic using the refresh_token grant type against POST https://api.helpscout.net/v2/oauth2/token. A common stumbling block: if your script runs for 6+ hours without refreshing, all subsequent API calls will return HTTP 401.
Rate limits are 400 requests per minute per access token. Responses include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers; on HTTP 429, respect the Retry-After header before retrying.
The load sequence for each ticket:
- Create or find the customer (
POST /v2/customersor search by email viaGET /v2/customers?query={email}) - Create the conversation (
POST /v2/conversations) with the first thread embedded andimported: true - Add remaining threads in chronological order using the type-specific endpoints
- Update custom fields (
PATCH /v2/conversations/{id}/fields) - Update tags (
PUT /v2/conversations/{id}/tags) - Set final status (
PATCH /v2/conversations/{id}withstatus: closed)
For a ticket with 10 engagements, you need roughly 12–15 API calls on the Help Scout side (1 conversation + 9 threads + custom fields + tags + status update). At 400 req/min, that is approximately 26–33 tickets per minute. A 10K-ticket migration takes about 5–6 hours of pure API time, excluding extraction. With attachments averaging 2 MB per ticket, expect 8–10 hours due to larger payloads and increased latency per request.
Step-by-Step Migration Process
Step 1: Extract Tickets and Engagements from HubSpot
import requests
import time
HUBSPOT_TOKEN = "pat-na1-xxxxx"
BASE_URL = "https://api.hubapi.com"
def get_all_tickets(properties):
"""Paginate through all tickets via CRM Search API."""
tickets = []
after = None
while True:
payload = {
"limit": 100,
"properties": properties,
"sorts": [{"propertyName": "createdate", "direction": "ASCENDING"}]
}
if after:
payload["after"] = after
resp = requests.post(
f"{BASE_URL}/crm/v3/objects/tickets/search",
headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
json=payload
)
resp.raise_for_status()
data = resp.json()
tickets.extend(data["results"])
if "paging" in data and "next" in data["paging"]:
after = data["paging"]["next"]["after"]
else:
break
time.sleep(0.06) # ~16 req/s, well under 190/10s
return tickets
def get_ticket_associations(ticket_id, to_object_type):
"""Get associations for a ticket (contacts, emails, notes)."""
resp = requests.get(
f"{BASE_URL}/crm/v4/objects/tickets/{ticket_id}/associations/{to_object_type}",
headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"}
)
resp.raise_for_status()
return resp.json().get("results", [])
def get_engagement_content(object_type, object_id, properties):
"""Fetch full engagement content via CRM v3 objects API (not deprecated v1)."""
resp = requests.get(
f"{BASE_URL}/crm/v3/objects/{object_type}/{object_id}",
headers={"Authorization": f"Bearer {HUBSPOT_TOKEN}"},
params={"properties": ",".join(properties)}
)
resp.raise_for_status()
return resp.json()Step 2: Transform Data
import re
from datetime import datetime, timezone
def transform_ticket_to_conversation(ticket, engagements, mailbox_id, agent_map, pipeline_stage_map):
"""Transform a HubSpot ticket + engagements into a Help Scout conversation."""
# Sort by timestamp; fall back to createdate if hs_timestamp is null
sorted_engagements = sorted(
engagements,
key=lambda e: e["properties"].get("hs_timestamp") or e["properties"].get("hs_createdate", "")
)
# Map pipeline stage label to Help Scout status (not hardcoded IDs)
stage_id = ticket["properties"].get("hs_pipeline_stage", "")
stage_label = pipeline_stage_map.get(stage_id, "closed").lower()
if stage_label in ("new", "waiting on contact", "waiting on us"):
status = "active"
elif "pending" in stage_label:
status = "pending"
else:
status = "closed"
# Convert HubSpot epoch ms to ISO 8601 UTC
created_at = convert_hubspot_timestamp(ticket["properties"].get("createdate"))
customer_email = ticket.get("_customer_email", "")
if not customer_email:
# Log and assign placeholder — Help Scout requires a customer email
customer_email = f"unknown+{ticket['id']}@migration.invalid"
conversation = {
"subject": ticket["properties"].get("subject") or "(No subject)",
"mailboxId": mailbox_id,
"status": status,
"type": "email",
"customer": {"email": customer_email},
"tags": extract_tags(ticket),
"imported": True,
"createdAt": created_at,
"threads": []
}
for eng in sorted_engagements:
thread = build_thread(eng, agent_map)
if thread:
conversation["threads"].append(thread)
# Cap at 100 threads — Help Scout returns HTTP 412 above this limit
if len(conversation["threads"]) > 100:
truncated_count = len(conversation["threads"]) - 99
conversation["threads"] = conversation["threads"][:99]
conversation["threads"].append({
"type": "note",
"text": f"Migration note: {truncated_count} older threads were truncated due to "
f"Help Scout's 100-thread limit. See HubSpot ticket #{ticket['id']} for full history.",
"imported": True
})
return conversation
def convert_hubspot_timestamp(ts_value):
"""Convert HubSpot timestamp (ms since epoch or ISO string) to ISO 8601 UTC."""
if not ts_value:
return None
if isinstance(ts_value, (int, float)):
return datetime.fromtimestamp(ts_value / 1000, tz=timezone.utc).isoformat()
# Already ISO format — ensure timezone
if "T" in str(ts_value) and not str(ts_value).endswith("Z") and "+" not in str(ts_value):
return str(ts_value) + "Z"
return str(ts_value)
def build_thread(engagement, agent_map):
"""Convert a HubSpot engagement to a Help Scout thread dict."""
eng_type = engagement.get("properties", {}).get("hs_object_type", "")
body = engagement.get("properties", {}).get("hs_body_preview", "")
# Handle empty bodies
if not body or not body.strip():
body = "[Empty message body — see original in HubSpot]"
# Sanitize HubSpot tracking pixels and CTA wrappers
body = sanitize_hubspot_html(body)
thread = {
"text": body,
"imported": True,
"createdAt": convert_hubspot_timestamp(
engagement["properties"].get("hs_timestamp")
)
}
# Map engagement type to Help Scout thread type
if eng_type == "emails":
# Determine direction from hs_email_direction property
direction = engagement["properties"].get("hs_email_direction", "")
thread["type"] = "customer" if direction == "INCOMING_EMAIL" else "reply"
elif eng_type == "notes":
thread["type"] = "note"
elif eng_type == "calls":
thread["type"] = "phone"
else:
thread["type"] = "note"
# Assign agent for outbound threads
owner_id = engagement["properties"].get("hubspot_owner_id")
if owner_id and owner_id in agent_map and thread["type"] in ("reply", "note", "phone"):
thread["user"] = agent_map[owner_id]
return thread
def sanitize_hubspot_html(html_body):
"""Remove HubSpot tracking pixels, CTA wrappers, and analytics tags."""
# Remove tracking pixels
html_body = re.sub(
r'<img[^>]*src=["\']https?://[^"\']*track\.hubspot\.com[^"\']*["\'][^>]*/?>',
'', html_body, flags=re.IGNORECASE
)
# Remove HubSpot CTA wrapper divs
html_body = re.sub(r'<div[^>]*class=["\']hs-cta[^"\']*["\'][^>]*>.*?</div>', '', html_body, flags=re.DOTALL)
return html_body
def extract_tags(ticket):
"""Extract tags from ticket properties. Map priority and category to tags."""
tags = []
priority = ticket["properties"].get("hs_ticket_priority")
if priority:
tags.append(f"priority:{priority.lower()}")
category = ticket["properties"].get("hs_ticket_category")
if category:
tags.append(f"category:{category.lower()}")
return tagsStep 3: Load into Help Scout
import time
HELPSCOUT_CLIENT_ID = "your-app-id"
HELPSCOUT_CLIENT_SECRET = "your-app-secret"
HS_BASE = "https://api.helpscout.net/v2"
# Token management — Help Scout OAuth tokens expire after 48 hours
_token_cache = {"access_token": None, "refresh_token": None, "expires_at": 0}
def get_helpscout_token():
"""Get or refresh Help Scout OAuth 2.0 token."""
if time.time() < _token_cache["expires_at"] - 300: # 5-min buffer
return _token_cache["access_token"]
if _token_cache["refresh_token"]:
payload = {
"grant_type": "refresh_token",
"refresh_token": _token_cache["refresh_token"],
"client_id": HELPSCOUT_CLIENT_ID,
"client_secret": HELPSCOUT_CLIENT_SECRET
}
else:
payload = {
"grant_type": "client_credentials",
"client_id": HELPSCOUT_CLIENT_ID,
"client_secret": HELPSCOUT_CLIENT_SECRET
}
resp = requests.post(f"{HS_BASE}/oauth2/token", json=payload)
resp.raise_for_status()
data = resp.json()
_token_cache["access_token"] = data["access_token"]
_token_cache["refresh_token"] = data.get("refresh_token")
_token_cache["expires_at"] = time.time() + data["expires_in"]
return _token_cache["access_token"]
def get_thread_endpoint(thread_type, conv_id):
"""Map thread type to Help Scout API endpoint."""
endpoints = {
"customer": f"{HS_BASE}/conversations/{conv_id}/customer",
"reply": f"{HS_BASE}/conversations/{conv_id}/reply",
"note": f"{HS_BASE}/conversations/{conv_id}/notes",
"phone": f"{HS_BASE}/conversations/{conv_id}/phones",
"chat": f"{HS_BASE}/conversations/{conv_id}/chats"
}
return endpoints.get(thread_type, endpoints["note"])
def create_conversation(conv_data):
"""Create conversation with first thread, then add remaining threads."""
token = get_helpscout_token()
first_thread = conv_data["threads"][0]
payload = {
"subject": conv_data["subject"],
"type": conv_data["type"],
"mailboxId": conv_data["mailboxId"],
"status": conv_data["status"],
"customer": conv_data["customer"],
"threads": [first_thread],
"tags": conv_data.get("tags", []),
"imported": True # Prevents auto-notifications and preserves timestamps
}
resp = requests.post(
f"{HS_BASE}/conversations",
headers={"Authorization": f"Bearer {token}"},
json=payload
)
resp.raise_for_status()
conv_id = resp.headers.get("Resource-ID")
# Add remaining threads in chronological order
for thread in conv_data["threads"][1:]:
token = get_helpscout_token() # Refresh if needed
endpoint = get_thread_endpoint(thread["type"], conv_id)
resp = requests.post(
endpoint,
headers={"Authorization": f"Bearer {token}"},
json=thread
)
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", 60))
time.sleep(wait)
resp = requests.post(
endpoint,
headers={"Authorization": f"Bearer {token}"},
json=thread
)
resp.raise_for_status()
time.sleep(0.15) # Stay under 400 req/min
return conv_idSet "imported": true on conversation creation. Without this flag, Help Scout sends real email notifications to customers for every imported conversation. This is the single most common catastrophic mistake in DIY migrations.
Step 4: Log and Track State
Maintain a ledger table with hubspot_ticket_id, helpscout_conversation_id, run ID, and load status. This handles retries and prevents duplicates. Write every 4xx/5xx response to a structured log with the HubSpot ticket ID, the endpoint, and the response body. Keep failed records in a dead-letter queue instead of silently skipping them.
import json
import logging
# Structured logging for migration audit trail
migration_logger = logging.getLogger("migration")
def log_migration_result(hubspot_id, helpscout_id, status, error=None):
"""Log every migration result for audit and retry."""
record = {
"hubspot_ticket_id": hubspot_id,
"helpscout_conversation_id": helpscout_id,
"status": status, # "success", "failed", "retrying"
"error": str(error) if error else None,
"timestamp": datetime.now(timezone.utc).isoformat()
}
migration_logger.info(json.dumps(record))
# Also write to your staging DB for retry logicEdge Cases and Failure Modes
Thread Cap (100 per Conversation)
A single conversation can contain up to 100 threads. If you try to create conversation with more than 100 threads or add a thread to a conversation that has 99 threads or more, the API will return HTTP 412 Precondition failed error.
For HubSpot tickets with 100+ engagements: truncate to the most recent 99 threads and add a note as the 100th thread summarizing the omitted history. Log these tickets separately for manual review.
Inline Images
HubSpot email engagements may contain inline images as CID references, authenticated URLs, or base64 strings. These URLs may become inaccessible after you leave HubSpot. Download all inline images during extraction, re-host them (S3, Cloudflare R2), and rewrite the src attributes in the HTML body before importing into Help Scout.
Attachment Size and Encoding
Help Scout expects attachments as base64-encoded data inline in the thread creation request. Large attachments (10 MB+) can cause request timeouts. Download from HubSpot's file API, check size, and for oversized files, link to an external URL in the thread body instead.
Duplicate Customers
HubSpot may have multiple contacts with the same email. Help Scout deduplicates customers by email. Run deduplication on HubSpot contacts before extraction. If duplicates reach Help Scout's API, the second POST /v2/customers with the same email will fail — search first with GET /v2/customers?query=email:{email}.
Custom Field Overflow
When HubSpot has more than 10 custom properties per ticket pipeline, the overflow must go somewhere:
- Prepend to a note: Add a structured note thread with all overflow properties as key-value pairs
- Map to tags: Convert single-select picklists to Help Scout tags (e.g.,
priority:high,category:billing) - Drop: If a property has <5% fill rate, it may not be worth preserving
Agent Matching
The API does not support user creation via REST. All Help Scout agents must be provisioned via the UI (or SCIM on Pro plan) before migration. Build a lookup table mapping HubSpot owner IDs to Help Scout user IDs. For tickets assigned to deactivated HubSpot users, assign to a default "Migration" agent or leave unassigned.
Truncated Messages
If your HubSpot account uses the legacy Conversations API, messages may be returned with a truncated flag. If you do not fetch original content for these messages, your migrated history will look complete but will be missing content. Always check for truncation and fetch the full body before transforming. (developers.hubspot.com)
High-Volume Customers
Updating a customer with more than 5,000 conversations will result in a 423 Locked error response. If you have high-volume customers, sequence their conversations carefully and monitor for this error. Consider splitting the load for these customers across multiple migration runs.
Rate Limiting Across Both Platforms
HubSpot private apps now allow 190 calls/10s; with the capacity pack up to 250/10s. Help Scout rate limits are 400 requests per minute per access token.
Implement a token bucket rate limiter on the extraction side and respect Retry-After headers on the load side. Use exponential backoff (starting at 1 second, doubling up to 60 seconds) for all 429 responses. Log every rate-limit event — if you consistently hit limits, reduce concurrency rather than relying on retry loops.
Delta Sync During Migration Window
For migrations spanning multiple days, tickets created in HubSpot during the migration window will be missed. Two approaches:
- Polling: After the main migration completes, query HubSpot for tickets created after your extraction start timestamp using
GET /crm/v3/objects/tickets/searchwith acreatedatefilter. Run this as a final catch-up pass. - Webhooks: Configure a HubSpot webhook subscription for
ticket.creationevents during the migration window. Queue new ticket IDs and process them in a final batch after the main migration completes. This requires a running webhook receiver (e.g., a simple Flask/Express endpoint writing to a queue).
For most teams, the polling approach is simpler and sufficient. Run the delta sync over a weekend when ticket volume is lowest.
Limitations You Cannot Work Around
- No SLA engine in Help Scout. HubSpot SLA policies and escalation rules do not transfer. Rebuild time-based workflows in Help Scout if on Plus or Pro.
- No pipelines. HubSpot ticket pipelines have no equivalent. You can use mailboxes, folders, or tags as a substitute, but there is no Kanban-style pipeline view.
- No deals or custom objects. HubSpot's CRM objects beyond contacts and companies do not exist in Help Scout. If your support workflow references deals, keep them in HubSpot and surface them via the native Help Scout integration. (docs.helpscout.com)
- Custom fields are inbox-scoped. Any single custom field belongs specifically to a single inbox. If you move a conversation from one inbox to another, the custom field data will not carry over even if you have custom fields in that inbox with the same name.
- No bulk import API. Help Scout does not offer a batch/bulk conversation import endpoint. Every conversation and thread is created individually.
- Thread immutability. Once a thread is created in Help Scout, its body cannot be updated via the API. If you make a mistake during import, you must delete the entire conversation and recreate it.
- No user creation via API. Agents must be provisioned manually or via SCIM (Pro plan only) before migration begins.
- No sandbox environment. Help Scout does not offer a dedicated sandbox or staging environment. Test migrations must run against a real mailbox — create a dedicated test mailbox and delete test conversations afterward via
DELETE /v2/conversations/{id}.
Validation and Testing
Do not assume a 200 OK response means the data is correct.
Record Count Comparison
| Check | HubSpot Source | Help Scout Target |
|---|---|---|
| Total tickets/conversations | CRM Search API total | List Conversations API total |
| Total threads per conversation | Engagement count per ticket | Thread count per conversation |
| Total customers | Contact count | Customer count |
| Total organizations | Company count | Organization count |
Field-Level Validation
Sample 50–100 conversations across different mailboxes. For each:
- Verify subject line matches
- Verify thread count matches (or is capped at 100 with a truncation note)
- Verify custom field values
- Open attachments and confirm they are readable
- Check agent assignment
- Confirm closed conversations remain closed (status drift is a common bug — conversations may revert to
activeif the finalPATCHwithstatus: closedfails silently) - Verify thread chronological order matches HubSpot's timeline
UAT Process
- Migrate a test batch of 100 tickets to a dedicated test mailbox in Help Scout
- Have 2–3 agents review their own historical conversations
- Fix mapping issues
- Run a second test batch of 500 tickets
- Sign off and proceed to full migration
Rollback Plan
Help Scout supports conversation deletion via DELETE /v2/conversations/{id}. If a migration run goes wrong, you can delete all imported conversations and re-run. Tag all migrated conversations with a hubspot-migrated tag for easy identification and bulk deletion.
Knowledge Base Migration (Docs API v1)
Knowledge base articles migrate separately from conversations using Help Scout's Docs API v1, which has its own authentication and endpoints.
Structure Mapping
| HubSpot KB | Help Scout Docs | Notes |
|---|---|---|
| Knowledge Base | Site (Docs site) | Create via Docs API or UI |
| Category | Collection | POST /v1/collections |
| Subcategory | Category (within collection) | POST /v1/categories |
| Article | Article | POST /v1/articles |
Migration Steps
- Export HubSpot KB articles via
GET /cms/v3/blogs/posts(KB articles use the blog posts API) or manually via HubSpot's export tool - Create Help Scout Docs collections and categories to match your HubSpot structure
- For each article, create via
POST /v1/articleswithcollectionId,text(HTML body),name(title), andstatus(publishedornotpublished) - Re-upload images — HubSpot-hosted images in article bodies will become inaccessible after you leave HubSpot. Download and re-host before importing.
- Update internal links between articles to point to new Help Scout Docs URLs
The Docs API v1 has a separate rate limit of 200 requests per minute. For a KB with 200+ articles, budget 30–60 minutes of API time.
Post-Migration Tasks
Rebuild Automations
HubSpot workflows (ticket routing, SLA escalation, auto-assignment) must be rebuilt as Help Scout workflows. Help Scout workflows use if/then rules based on conversation properties, tags, and custom fields. Document every active HubSpot workflow before migration and recreate each one manually.
Recreate Saved Replies
HubSpot snippets and templates do not transfer via API. Export your snippet library from HubSpot and manually create saved replies in Help Scout.
Reconnect Integrations
- Reconnect third-party integrations (Jira, Slack, Shopify) to the new Help Scout mailbox
- Update DNS records if you are changing support email forwarding rules
- If HubSpot remains your CRM, connect it intentionally — Help Scout's native HubSpot app can surface the sales and marketing context agents still need without forcing those objects into the help desk. (docs.helpscout.com)
Agent Training
Help Scout's interface is fundamentally different from HubSpot's. Plan a 1–2 hour walkthrough covering:
- Mailbox navigation vs pipeline navigation
- Conversation assignment and collision detection
- Workflow triggers and conditions
- Beacon (Help Scout's embeddable widget) setup
- What data now lives in HubSpot versus Help Scout
Monitoring
After go-live, monitor for 1–2 weeks:
- Missing conversations (compare daily counts)
- Broken attachments or inline images
- Customer complaints about receiving old emails (indicates
imported: truewas not set) - Custom field values showing as blank
- Status drift on closed conversations
Best Practices
- Back up everything first. Export all HubSpot data to your own storage before starting. HubSpot's export API generates downloadable files.
- Never migrate production data on the first try. Migrate 100 tickets to a test mailbox, validate, iterate. Then run a 500-ticket batch before going full-scale.
- Set
imported: trueon every conversation. Failing to use this flag will result in thousands of old emails being sent to your customers. - Provision agents early. All Help Scout agents must exist before the first conversation import. Build your HubSpot owner ID → Help Scout user ID lookup table before extraction begins.
- Tag everything. Add a migration-specific tag (e.g.,
hubspot-migrated) to every imported conversation. This lets you filter, audit, and roll back if needed. - Plan for delta sync. If the migration takes more than a day, tickets created in HubSpot during the migration window will be missed. Use a polling-based catch-up pass or webhook queue to capture tickets created during the window.
- Log every failure. Write every 4xx/5xx response to a structured log with the HubSpot ticket ID, the endpoint, and the response body. You will need this for debugging.
- Implement OAuth token refresh. Help Scout access tokens expire after 48 hours. For long-running migrations, refresh proactively before expiry to avoid HTTP 401 errors mid-batch.
- Migrate newest-first for incremental approaches. Agents need current context immediately; historical tickets can backfill.
- Do not collapse CRM data into Help Scout unless agents actually use it. Keep deals, custom objects, and marketing context in HubSpot. Surface it through the native integration where needed.
For more on post-migration validation, see our Post-Migration QA Checklist. If you're evaluating Help Scout against alternatives before committing, our Freshdesk vs Help Scout vs Zendesk comparison covers the operational trade-offs.
Making the Right Call
Migrating from HubSpot Service Hub to Help Scout is a move from a complex, relational CRM platform to a focused, email-first support tool. The migration itself is straightforward in concept — extract tickets, transform to conversations, load via API — but the constraints are where projects break: 10 custom fields per inbox, 100 threads per conversation, no bulk import, no pipeline equivalent, no SLA engine, and no sandbox for testing.
If you have a small account (under 5K tickets) and an engineer who can dedicate a week, DIY is viable. For anything larger, or if your data includes complex attachments, multi-pipeline structures, or inline images, the risk of data loss or broken context makes a managed migration the safer path.
The cleanest operating model for many teams is: move support history into Help Scout, keep CRM-native objects in HubSpot, and connect the two where agents still need context. If you want the broader build-versus-tool decision frame, our Help Desk Migration Alternatives guide is the companion read.
Frequently Asked Questions
- Can I migrate HubSpot Service Hub tickets to Help Scout using CSV?
- No. Help Scout has no native CSV import for conversations. You can export contacts via CSV, but ticket history, thread content, attachments, and notes require the Help Scout Mailbox API 2.0. HubSpot's CSV export only gives you ticket metadata — not the full thread content or engagement bodies needed for a complete migration.
- What are Help Scout's API rate limits for migration?
- Help Scout allows 400 API requests per minute per access token. Each ticket migration requires roughly 12–15 API calls (conversation creation plus threads, custom fields, tags, and status update). That means approximately 26–33 tickets per minute, making a 10K-ticket migration take about 5–6 hours of pure API time.
- How many custom fields does Help Scout support per inbox?
- Help Scout supports a maximum of 10 custom fields per inbox, available on Plus and Pro plans only. There are 5 field types: Dropdown, Single line, Multi-line, Number, and Date. Custom fields are inbox-specific and do not carry over if a conversation is moved between inboxes.
- What happens to HubSpot deals and custom objects in Help Scout?
- Help Scout has no native deal pipeline or custom object layer. Most teams keep deals and custom objects in HubSpot and surface the needed context in Help Scout through the native HubSpot app, which shows contact properties, activity, and deals in the sidebar.
- Will my customers receive old emails during the migration?
- Not if executed correctly. When creating conversations via the Help Scout API, you must set the imported flag to true. This suppresses all outbound customer notifications and workflow triggers, and preserves original timestamps.



