Unthread to Help Scout Migration: The Technical Guide
A technical guide to migrating from Unthread to Help Scout. Covers API extraction, data model mapping, thread-type inference, edge cases, and validation.
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
Unthread to Help Scout Migration: The Technical Guide
Migrating from Unthread to Help Scout means translating a Slack-native, AI-powered ticketing model into an email-first shared inbox platform. There is no push-button migration path between these two systems. Help Scout's official import flow is powered by Import2, and its published supported-source list does not include Unthread — unsupported sources are directed to the Mailbox API. (docs.helpscout.com)
This is an API-to-API data translation project. Unthread relies on Slack user IDs, channel states, and continuous message threads. Help Scout structures data around Mailboxes, Customers (identified by email), and discrete Conversations containing typed threads. Every Unthread message must be mapped to the correct Help Scout thread type, every Slack identity must be resolved to an email address, and every attachment must be re-uploaded.
This guide covers the complete technical path: data model mapping, API constraints on both sides, migration approaches with honest trade-offs, step-by-step process, edge cases, and validation.
Why Teams Move from Unthread to Help Scout
Unthread is a Slack-native AI helpdesk that converts Slack conversations into tracked, routed, and resolved tickets. It handles internal support (IT, HR, Finance) and B2B customer support via Slack Connect channels. Its data model is inherently tied to Slack's architecture.
Help Scout is an email-first shared inbox platform built for external customer-facing support. It centers on Conversations, a Beacon live chat widget, Docs knowledge bases, and customer profiles. Pricing runs from Free (5 users) through Standard ($25/user/month), Plus ($45/user/month), to Pro ($75/user/month).
The common triggers for this migration:
- Channel shift: The team is moving from Slack-centric support to email-centric support, and Unthread's Slack-native model no longer fits.
- Cost consolidation: Teams already using Help Scout for external support want one platform rather than two.
- Workflow simplicity: Help Scout's shared inbox model is lighter-weight for teams that don't need Slack-native intake, AI automations, or ticket type forms.
- Ecosystem fit: Help Scout's integrations with Salesforce, HubSpot, and Jira (on Plus/Pro plans) align better with the team's stack.
- Multi-brand support: Help Scout handles agency and multi-brand use cases with multiple mailboxes per account, which maps more naturally than a Slack-native tool for teams managing several brands.
Core Differences in Data Model
Understanding the architectural gap is the first step toward a clean migration. These are not equivalent systems with different UIs — they represent fundamentally different data models.
| Concept | Unthread | Help Scout |
|---|---|---|
| Primary entity | Conversation (Slack thread) | Conversation (email thread) |
| Customer identity | Customer (linked to Slack channel) + Account | Customer (email-based, globally unique email) |
| Messages | Messages within a conversation thread | Threads (typed: customer, reply, note, chat, phone) |
| Custom fields | Ticket Type Fields (per ticket type, supports conditional logic) | Custom Fields (per mailbox, max 10, no conditional logic) |
| Categorization | Tags + Ticket Types | Tags + Custom Fields |
| Knowledge base | Articles (API-managed, AI-indexed) | Docs (collections + articles, separate site) |
| Users | Synced from Slack workspace | Users (invited via UI or SCIM on Pro plan) |
| Automation | Automations (event/webhook/scheduled triggers) | Workflows (rule-based, if/then conditions) |
| Accounts/orgs | Accounts (group customers, assign reps, custom fields) | Companies (lightweight grouping, limited custom properties) |
The biggest structural difference: Unthread conversations are Slack-thread-native with messages as first-class children. Help Scout conversations are email-native with typed threads (customer, reply, note) as children. Shared Slack channels, multi-party participation, ticket-type fields, and automation behavior do not map 1:1 into Help Scout. (docs.unthread.io)
Migration Approaches
There are five viable methods. Each has different trade-offs on fidelity, engineering effort, and scale.
1. API-Based Migration (Unthread API → Help Scout API)
How it works: Write a custom script that extracts data from Unthread's REST API, transforms it into Help Scout's schema, and loads it via Help Scout's Mailbox API 2.0. (docs.unthread.io)
When to use: Any migration where you need full conversation history with message-level fidelity.
Pros: Highest data fidelity. Full control over transformation logic. Handles relationships and custom fields. Preserves timestamps via createdAt.
Cons: Requires engineering time. Must handle rate limits, pagination, retries, token refresh, and Slack-to-email identity resolution. Thread ordering requires careful sequencing.
Scalability: Works for any dataset size. For large volumes (50K+ conversations), implement batching, checkpointing, and parallel extraction.
Risks: Help Scout's 100-thread-per-conversation limit can truncate long Unthread threads. Write requests count as 2 toward Help Scout's rate limit, so on the Standard plan (200 req/min cap) your effective write throughput is ~100 write operations per minute.
Complexity: High
2. Native Export/Import (CSV-Based)
How it works: Export available data from Unthread (via analytics reports or API to CSV), then attempt to import into Help Scout.
When to use: When you only need to migrate basic customer contact records and are willing to abandon historical conversation data.
Pros: Lowest upfront engineering effort.
Cons: Unthread has no native full-data CSV export — you still need the API for extraction. Help Scout's native CSV importer only supports Customer profiles. You cannot import Conversations, Threads, or Notes via CSV. This approach is functionally a simplified version of API-based migration with much lower fidelity.
Scalability: Low. Manual CSV manipulation breaks down past a few hundred records.
Risks: Lossy. You will miss message bodies, attachments, and thread structure.
Complexity: Medium
3. Third-Party Migration Tools
How it works: Use a dedicated migration service like Help Desk Migration that may support Unthread as a source and Help Scout as a target.
When to use: Teams without engineering bandwidth who want a guided, wizard-based migration.
Pros: No code required. Pre-built field mapping. Demo migration available before committing.
Cons: Limited control over edge-case transformations. Ticket type fields and custom objects may not map cleanly. Pricing scales with record count. Help Scout officially routes imports through Import2, but Unthread is not on the published supported-source list — confirm source support before betting your cutover on a tool. (docs.helpscout.com)
Scalability: Good for small-to-mid volumes. Enterprise-scale datasets may require custom handling.
Risks: Vendor-dependent. If the tool doesn't support a specific Unthread object (e.g., Accounts, Approval Requests), you lose that data.
Complexity: Low
4. Middleware/Integration Platforms (Zapier, Make)
How it works: Use Zapier or Make to create workflows that read from Unthread and write to Help Scout. Unthread offers webhooks and webhook-triggered automations that can feed these flows. (docs.unthread.io)
When to use: Ongoing sync scenarios where you want new Unthread conversations to flow into Help Scout in real-time during a transition period.
Pros: No-code setup. Good for incremental sync during a phased cutover.
Cons: Not designed for bulk historical migration. Rate limits and execution time caps on middleware platforms make large migrations impractical. Thread ordering, attachment handling, and deep backfill are awkward in no-code flows.
Scalability: Poor for historical migration. Acceptable for forward-looking sync of new records.
Risks: Partial data. Middleware typically doesn't handle full conversation threads, attachments, or custom fields.
Complexity: Low
5. Managed Migration Service
How it works: Engage a migration specialist to handle the full extraction, transformation, and loading process.
When to use: When you need high data fidelity, have limited engineering bandwidth, or are dealing with complex data (custom fields, multi-level relationships, large volumes, tight timelines).
Pros: Expert handling of edge cases, attachments, and relationship preservation. Reduced risk. Faster turnaround.
Cons: External cost. Requires sharing API credentials (mitigated by scoped service accounts with limited permissions).
Scalability: Excellent. Managed services are built for volume.
Risks: Minimal if the provider has experience with both platforms.
Complexity: Low (for your team)
Migration Approach Comparison
| Approach | Data Fidelity | Engineering Effort | Best For |
|---|---|---|---|
| API-to-API | High | High | Full-fidelity migration with custom logic |
| CSV-based | Low | Medium | Metadata-only, small datasets |
| Third-party tool | Medium | Low | Small teams, no dev resources |
| Middleware (Zapier/Make) | Low | Low | Ongoing sync during transition |
| Managed service | High | Low | Complex data, tight timelines |
Recommendations by scenario:
- Small team, <1K conversations, no custom fields: Third-party tool or simplified API script.
- Mid-size team, 1K–50K conversations, ticket type fields: API-based migration or managed service.
- Enterprise, 50K+ conversations, Accounts + custom fields + attachments: Managed migration service.
- Ongoing sync during transition: Middleware for new records; API script for historical backfill.
- Low engineering bandwidth: Managed migration service — do not bet on a third-party tool until Unthread source support is proven in a sample migration.
Decision Framework: Build vs. Buy
Building an Unthread-to-Help-Scout migration in-house can look straightforward. In practice, the complexity concentrates in specific areas that are easy to underestimate:
- Thread-type translation logic is non-trivial. Unthread messages don't have a
typefield — you have to infer whether each message is a customer message, an agent reply, or an internal note based on the sender's role,isPrivateNoteflags, and metadata event types. Themetadata.eventTypeenum is not fully documented in Unthread's public API reference, requiring validation against your actual data (see the inference section below). - Attachment handling requires downloading files from Unthread's file endpoint, base64-encoding them, and embedding them in Help Scout thread payloads. At scale, this compounds: a migration with 10K conversations averaging 1.5 attachments each means 15K file downloads, encodings, and uploads.
- Rate limit management across two APIs with different throttling models requires careful orchestration. Unthread uses cursor pagination without formally documented rate limits; Help Scout enforces plan-based limits with writes counting double.
- Relationship preservation — linking conversations to the correct Help Scout customer, applying the right tags, and mapping ticket type fields to custom fields — requires extensive mapping logic and pre-creation of dependencies.
- Identity resolution from Slack IDs to email addresses must happen before any conversation can be attributed correctly. If this mapping is wrong, historical context is lost and customer relationships are broken.
Throughput calculation for planning: On Help Scout's Standard plan (200 req/min), with write requests counting as 2, effective write throughput is ~100 write operations per minute. A conversation with 5 threads requires 1 conversation create + 4 thread creates = 5 write operations. At that rate, 10K conversations with an average of 5 threads each = 50K write operations ÷ 100/min = ~8.3 hours of pure write time, excluding extraction, transformation, retries, and rate-limit pauses. On the Plus plan (400 req/min), that drops to ~4.2 hours; on Pro (800 req/min), ~2.1 hours. Extraction time from Unthread adds to this: fetching messages for each of 10K conversations individually (no bulk endpoint) at a conservative 3 req/sec takes ~55 minutes.
The hidden cost is not the initial build — it's the debugging, re-runs, and validation cycles. A 10K-conversation migration typically consumes 2–3 engineering weeks once you account for edge cases, retries, identity resolution, and UAT — based on the throughput math above plus time spent on mapping, debugging, and validation.
Consider outsourcing if any of these apply: you need a weekend cutover with no margin for error, you have large attachments or dense conversation history (avg >50 messages/conversation), support must keep running during the migration, or Unthread is only one part of a bigger CRM/helpdesk change.
Disclosure: ClonePartner, the publisher of this guide, offers managed helpdesk migration services. We handle the full pipeline — extraction, transformation, loading, relationship rebuild, and validation. If Unthread's Slack-native data model or Help Scout's thread constraints are a concern, we can discuss your specific scenario.
Pre-Migration Planning
Data Audit Checklist
Before writing any code, inventory what exists in Unthread:
- Customers — count, linked Slack channels, email domains (how many have no email?)
- Accounts — count, custom fields, support step configurations
- Conversations — total count, by status (open/in_progress/on_hold/closed)
- Messages per conversation — average and maximum (flag any exceeding 100 for Help Scout's thread limit)
- Tags — full list, auto-tagging rules
- Ticket Types — types, custom field definitions (short-answer, single-select, multi-select, checkbox, user-select)
- Knowledge Base articles — count, active vs. inactive, content format (markdown/HTML)
- File attachments — total count, total size, files exceeding 10MB (Help Scout's per-file limit)
- Automations — document these for manual rebuild in Help Scout
- Users — count, role assignments
- Slack Connect channels — identify external participants whose identities need resolution
Identify What Not to Migrate
Not everything should make the trip:
- Slack-specific metadata:
slackChannelId,slackTeamId, triage thread references — these have no equivalent in Help Scout. Store them in notes for reference. - Automations and escalation rules: Help Scout uses Workflows with a different rule engine. These must be rebuilt manually.
- Approval requests: No equivalent in Help Scout. Archive as notes on the associated conversation.
- AI configurations: Unthread's AI responses, AI tags, and knowledge base query logic don't transfer. Help Scout's AI Answers is a separate product with its own configuration.
- Outbound messages and broadcasts: Help Scout doesn't have an equivalent broadcast feature.
For a deeper framework on scoping what to migrate, see our Help Desk Data Migration Playbook.
Migration Strategy
| Strategy | When to Use | Risk |
|---|---|---|
| Big bang | Small dataset (<5K conversations), clear cutover weekend | Higher risk if validation fails |
| Phased | Large dataset, multiple teams/projects | Lower risk, longer timeline |
| Incremental | Need to run both platforms in parallel | Requires sync logic, risk of data drift |
For most Unthread-to-Help-Scout migrations, a big bang approach over a weekend works well. Unthread's dataset tends to be compact relative to legacy helpdesks, and the Slack-native intake means there's usually a clean cutover point — stop creating new Unthread conversations, migrate, switch.
Data Model and Object Mapping
This is the most important section. Getting the mapping wrong here cascades into every downstream step.
Object-Level Mapping
| Unthread Object | Help Scout Equivalent | Notes |
|---|---|---|
| Customer | Customer | Map name + emailDomains → Help Scout customer with email |
| Account | Company | Unthread Accounts group customers; map to Help Scout Companies if on Plus/Pro. Account-level custom fields will be partially lost (Companies support limited properties). |
| Conversation | Conversation | Map status, subject, tags, timestamps |
| Message | Thread (customer/reply/note) | Infer thread type from sender role and metadata |
| Tag | Tag | Direct 1:1 mapping. Help Scout tags are case-insensitive and limited to 100 characters |
| Ticket Type | Custom Field + Tag | Ticket type name → tag; ticket type fields → custom fields |
| Ticket Type Field | Custom Field | Map field types carefully (see below) |
| Knowledge Base Article | Docs Article | Map title, content, status. Requires collection assignment in Help Scout. |
| User | User | Must be created in Help Scout UI or via SCIM (Pro only); map by email for conversation assignment |
| Automation | Workflow | Manual rebuild required — no automated mapping possible |
| Approval Request | Note (on conversation) | Archive as a note on the conversation for reference |
Field-Level Mapping: Conversations
| Unthread Field | Help Scout Field | Transformation |
|---|---|---|
title |
subject |
Direct map; fallback to "(no subject)" if null |
status (open/in_progress/on_hold/closed) |
status (active/pending/closed) |
open + in_progress → active; on_hold → pending; closed → closed |
priority (3/5/7/9) |
Custom field or tag | Help Scout has no native priority field. Map 3→Critical, 5→High, 7→Normal, 9→Low |
createdAt |
createdAt |
Direct map (ISO 8601). Must be explicitly set or Help Scout stamps with current time. |
closedAt |
closedAt |
Direct map |
assignedToUserId |
assignee (user ID) |
Requires pre-mapping Unthread user IDs to Help Scout user IDs |
customerId |
customer |
Map via email address resolved from Unthread customer record |
tags |
tags |
Create tags in Help Scout first, then apply |
ticketTypeFields |
fields (custom fields) |
Create custom fields in Help Scout mailbox first |
Field-Level Mapping: Ticket Type Fields → Custom Fields
| Unthread Field Type | Help Scout Custom Field Type | Notes |
|---|---|---|
short-answer |
Text | Direct map |
long-answer |
Text | Help Scout text fields have character limits |
single-select |
Dropdown | Map options 1:1 |
multi-select |
Dropdown (single-select only) | Help Scout dropdowns are single-select. Concatenate values into a pipe-delimited string, or split into multiple fields if count allows |
checkbox |
Dropdown or Tag | No native checkbox in Help Scout custom fields. Map true/false to a dropdown with "Yes"/"No" options |
user-select |
Text or Dropdown | Store user name/email as text |
Help Scout custom fields are per-mailbox, not per-conversation-type. If you have multiple Unthread Ticket Types with different field schemas, you'll need to flatten them into a single set of custom fields on the Help Scout mailbox, or split across multiple mailboxes. Help Scout supports up to 10 custom fields per mailbox and up to 50 contact properties plus 50 company properties. (developer.helpscout.com)
Handling Overflow Custom Data
If you used extensive custom metadata in Unthread — for example, 4 ticket types each with 5+ custom fields — you will exceed Help Scout's 10-field-per-mailbox limit. Options:
- Prioritize: Identify the 10 most operationally important fields. Migrate those to Help Scout custom fields.
- Notes overflow: For remaining fields, serialize values into a structured internal note on each conversation (e.g.,
Priority: High | Region: EMEA | Contract Tier: Enterprise). - Contact/company properties: Route person-level or account-level data to Help Scout's contact properties (up to 50) or company properties (up to 50) instead of conversation-level custom fields.
- Multiple mailboxes: If ticket types represent truly distinct workflows, map each to a separate Help Scout mailbox, each with its own 10 custom fields.
Migration Architecture
Data Flow
Unthread API ──► Extract (JSON) ──► Transform ──► Load ──► Help Scout API
│ │ │
├─ Customers/Accounts ├─ Schema mapping ├─ Create Customers
├─ Conversations + Messages ├─ Thread type inference ├─ Create Conversations
├─ Tags + Ticket Types ├─ Field normalization ├─ Apply Tags/Fields
├─ KB Articles ├─ Slack markdown → HTML ├─ Create Docs Articles
└─ Attachments (binary) └─ Attachment base64 └─ Upload in thread payloads
Unthread API: Extraction Details
- Base URL:
https://api.unthread.io/api/ - Authentication:
X-Api-Keyheader with service account key - Pagination: Cursor-based, 100 records per page max
- Rate limits: Not formally documented in public API reference. In testing, conservative pacing at 2–3 requests/second avoids throttling. Implement exponential backoff starting at 1 second on any HTTP 429 or 5xx response.
- API stability note: The Unthread API is labeled Beta. Endpoint behavior may change without notice. Pin your extraction code to specific field names and validate responses against expected schemas. (docs.unthread.io)
- Key endpoints:
POST /customers/list— extract all customersPOST /accounts/list— extract all accountsPOST /conversations/list— extract conversations withselectfor specific fieldsPOST /conversations/:id/messages/list— extract messages per conversationPOST /tags/list— extract all tagsPOST /ticket-types/list— extract ticket types with field definitionsPOST /knowledge-base/articles/list— extract KB articlesGET /conversations/:id/files/:fileId/full— download attachments (binary)
Help Scout API: Loading Details
- Base URL:
https://api.helpscout.net/v2/ - Authentication: OAuth 2.0 Client Credentials flow; tokens valid 48 hours. Long-running migrations must implement token refresh (see code below).
- Rate limits: Plan-dependent — up to 200/400/800 requests per minute on Standard/Plus/Pro plans. Write requests (POST, PUT, DELETE, PATCH) count as 2 requests toward the limit. Returns HTTP 429 with a
Retry-Afterheader when exceeded. (developer.helpscout.com) - Pagination: 50 records per page max on list endpoints
- Key constraints:
- Max 100 threads per conversation (hard limit, cannot be increased)
- User creation is not available via API — users must be invited via UI or provisioned via SCIM (Pro plan only; SCIM supports
userName,name,emails, andactiveattributes) - The
imported: trueflag on threads prevents the conversation from reopening and suppresses customer-facing email notifications - Attachments must be base64-encoded in thread payloads, max 10MB per file
createdAtcan be set when creating conversations and threads to preserve original timestamps- Customer email addresses must be globally unique across the Help Scout account
- A customer lock engages once a contact exceeds 5,000 conversations — validate any extreme high-volume addresses before the full run (developer.helpscout.com)
Dependency creation order: Property definitions and tags first → customers/companies → conversations with threads → post-load enrichment (custom field values, additional tags). Help Scout custom fields must exist before you can populate them on conversations. (developer.helpscout.com)
Step-by-Step Migration Process
Step 1: Extract Data from Unthread
import requests
import json
import time
import os
UNTHREAD_API_KEY = "your-api-key"
BASE_URL = "https://api.unthread.io/api"
HEADERS = {"X-Api-Key": UNTHREAD_API_KEY, "Content-Type": "application/json"}
OUTPUT_DIR = "unthread_export"
os.makedirs(OUTPUT_DIR, exist_ok=True)
def extract_all(endpoint, select_fields=None):
"""Generic paginated extraction from Unthread with disk persistence."""
all_records = []
cursor = None
while True:
payload = {"limit": 100}
if select_fields:
payload["select"] = select_fields
if cursor:
payload["cursor"] = cursor
resp = requests.post(f"{BASE_URL}/{endpoint}/list", headers=HEADERS, json=payload)
if resp.status_code == 429 or resp.status_code >= 500:
time.sleep(2 ** (len(all_records) // 1000 + 1)) # exponential backoff
continue
resp.raise_for_status()
data = resp.json()
all_records.extend(data["data"])
if not data["cursors"]["hasNext"]:
break
cursor = data["cursors"]["next"]
time.sleep(0.4) # conservative pacing (~2.5 req/sec)
return all_records
# Extract core objects
customers = extract_all("customers")
with open(f"{OUTPUT_DIR}/customers.jsonl", "w") as f:
for c in customers:
f.write(json.dumps(c) + "\n")
accounts = extract_all("accounts")
tags = extract_all("tags")
ticket_types = extract_all("ticket-types")
conversations = extract_all("conversations", [
"id", "title", "status", "priority", "createdAt", "closedAt",
"customerId", "assignedToUserId", "tags.id", "tags.name",
"ticketTypeId", "ticketTypeFields", "notes", "files"
])Step 2: Extract Messages per Conversation
def extract_messages(conversation_id):
"""Extract all messages for a single conversation."""
return extract_all(f"conversations/{conversation_id}/messages")
checkpoint_file = f"{OUTPUT_DIR}/extraction_checkpoint.json"
completed = set()
if os.path.exists(checkpoint_file):
with open(checkpoint_file) as f:
completed = set(json.load(f))
for conv in conversations:
if conv["id"] in completed:
continue
conv["messages"] = extract_messages(conv["id"])
# Write each conversation to disk immediately
with open(f"{OUTPUT_DIR}/conv_{conv['id']}.json", "w") as f:
json.dump(conv, f)
completed.add(conv["id"])
# Checkpoint every 50 conversations
if len(completed) % 50 == 0:
with open(checkpoint_file, "w") as f:
json.dump(list(completed), f)
time.sleep(0.3)Step 3: Transform Data
The transformation layer handles four critical jobs.
a) Map conversation status:
STATUS_MAP = {
"open": "active",
"in_progress": "active",
"on_hold": "pending",
"closed": "closed"
}b) Infer thread type from message metadata:
# Known Unthread metadata.eventType values observed in production data.
# This list may be incomplete — validate against your actual exported messages
# before running the full migration. Check for unlisted eventType values and
# add mapping rules for any new types found.
CUSTOMER_EVENT_TYPES = {
"email_received",
"widget_message_received",
"slack_bot_dm_received",
"slack_message_received",
"form_submitted",
}
BOT_EVENT_TYPES = {
"autoresponder_replied",
"post_close_template_sent",
"ai_response_sent",
"sla_warning_sent",
}
AGENT_EVENT_TYPES = {
"email_sent",
"slack_message_sent",
}
def infer_thread_type(message, agent_user_ids):
"""
Determine Help Scout thread type from Unthread message.
Returns: 'customer', 'reply', or 'note'
"""
sender_id = message.get("userId")
metadata = message.get("metadata", {})
event_type = metadata.get("eventType", "")
# Private notes → note
if message.get("isPrivateNote"):
return "note"
# Triage thread messages (internal discussion) → note
if message.get("triageThreadTs"):
return "note"
# Customer-originated messages
if event_type in CUSTOMER_EVENT_TYPES:
return "customer"
# Bot/system messages → note
if message.get("botId") or event_type in BOT_EVENT_TYPES:
return "note"
# Agent messages → reply
if event_type in AGENT_EVENT_TYPES:
return "reply"
if sender_id and sender_id in agent_user_ids:
return "reply"
# Unknown event type — log for review, default to customer
if event_type and event_type not in (CUSTOMER_EVENT_TYPES | BOT_EVENT_TYPES | AGENT_EVENT_TYPES):
print(f"WARNING: Unknown eventType '{event_type}' on message {message.get('id')}")
return "customer"The eventType enumeration above is based on observed values, not an exhaustive official list. Before running your migration, extract all unique metadata.eventType values from your Unthread messages and verify every type has a correct mapping. An unmapped event type defaults to "customer" thread, which may incorrectly expose internal messages to customers in Help Scout.
c) Convert Slack markdown to HTML:
import re
def slack_markdown_to_html(text):
"""Convert Slack-style markdown to HTML for Help Scout."""
if not text:
return ""
# Bold: *text* → <strong>text</strong>
text = re.sub(r'\*([^*]+)\*', r'<strong>\1</strong>', text)
# Italic: _text_ → <em>text</em>
text = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'<em>\1</em>', text)
# Strikethrough: ~text~ → <del>text</del>
text = re.sub(r'~([^~]+)~', r'<del>\1</del>', text)
# Code blocks: ```text``` → <pre><code>text</code></pre>
text = re.sub(r'```([^`]+)```', r'<pre><code>\1</code></pre>', text, flags=re.DOTALL)
# Inline code: `text` → <code>text</code>
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
# Links: <url|label> → <a href="url">label</a>
text = re.sub(r'<(https?://[^|>]+)\|([^>]+)>', r'<a href="\1">\2</a>', text)
# Plain URLs: <url> → <a href="url">url</a>
text = re.sub(r'<(https?://[^>]+)>', r'<a href="\1">\1</a>', text)
# Slack user mentions: <@U12345> → @U12345 (or resolve to names if you have a lookup)
text = re.sub(r'<@([A-Z0-9]+)>', r'@\1', text)
# Newlines → <br>
text = text.replace('\n', '<br>')
return textd) Build Help Scout conversation payload:
def transform_conversation(conv, customer_email_map, user_id_map, agent_user_ids):
customer_id = conv.get("customerId")
customer_email = customer_email_map.get(customer_id, "unknown@migration.local")
threads = []
sorted_messages = sorted(conv.get("messages", []), key=lambda m: m.get("timestamp", ""))
for msg in sorted_messages:
thread_type = infer_thread_type(msg, agent_user_ids)
thread = {
"type": thread_type,
"text": slack_markdown_to_html(msg.get("text", "")),
"imported": True,
"createdAt": msg.get("timestamp")
}
if thread_type == "customer":
thread["customer"] = {"email": customer_email}
elif thread_type == "reply":
hs_user_id = user_id_map.get(msg.get("userId"))
if hs_user_id:
thread["user"] = hs_user_id
threads.append(thread)
# Handle 100-thread limit
if len(threads) > 100:
truncation_note = {
"type": "note",
"text": f"<p>⚠️ This conversation had {len(sorted_messages)} messages in Unthread. "
f"Only the most recent 99 are shown. {len(sorted_messages) - 99} earlier messages were truncated.</p>",
"imported": True,
"createdAt": sorted_messages[0].get("timestamp")
}
threads = [truncation_note] + threads[-(99):]
return {
"subject": conv.get("title") or "(no subject)",
"customer": {"email": customer_email},
"mailboxId": TARGET_MAILBOX_ID,
"type": "email",
"status": STATUS_MAP.get(conv.get("status"), "closed"),
"createdAt": conv.get("createdAt"),
"threads": threads,
"imported": True,
"tags": [tag["name"] for tag in conv.get("tags", [])]
}Timestamps are critical. You must explicitly pass the createdAt field for both the Conversation and individual Threads. If omitted, Help Scout will stamp all historical messages with the current date, destroying your historical timeline.
Step 4: Pre-Create Users, Tags, and Custom Fields
Before loading conversations, set up the Help Scout environment:
- Users: Invite all agents via Help Scout UI (or SCIM on Pro plan — SCIM endpoint is
https://api.helpscout.net/v2/scim/v2/Users, supportinguserName,name.givenName,name.familyName,emails, andactiveattributes). Build an Unthread-user-ID-to-Help-Scout-user-ID lookup table by matching on email address. - Tags: Create all tags in the target mailbox. Help Scout tags are case-insensitive, max 100 characters, and must not contain commas.
- Custom Fields: Create custom fields in the target mailbox to match your Unthread ticket type field mapping. Remember the 10-field-per-mailbox limit.
Step 5: Load into Help Scout
import base64
HS_BASE = "https://api.helpscout.net/v2"
class HelpScoutAuth:
"""Handles OAuth token management with automatic refresh."""
def __init__(self, client_id, client_secret):
self.client_id = client_id
self.client_secret = client_secret
self.token = None
self.token_expires_at = 0
def get_token(self):
if self.token and time.time() < self.token_expires_at - 300: # refresh 5 min early
return self.token
resp = requests.post(f"{HS_BASE}/oauth2/token", json={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret
})
resp.raise_for_status()
data = resp.json()
self.token = data["access_token"]
self.token_expires_at = time.time() + data.get("expires_in", 172800) # default 48h
return self.token
auth = HelpScoutAuth("your-client-id", "your-client-secret")
def create_conversation(payload, load_log):
"""Create a conversation in Help Scout with retry logic."""
token = auth.get_token()
resp = requests.post(f"{HS_BASE}/conversations",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=payload
)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 60))
time.sleep(retry_after)
return create_conversation(payload, load_log)
if resp.status_code == 201:
hs_id = resp.headers.get("Resource-ID")
load_log.append({"unthread_id": payload.get("_source_id"), "hs_id": hs_id, "status": "success"})
return hs_id
else:
load_log.append({
"unthread_id": payload.get("_source_id"),
"status": "failed",
"http_status": resp.status_code,
"error": resp.text
})
return NoneStep 6: Handle Attachments
def download_and_encode_attachment(conversation_id, file_id):
"""Download attachment from Unthread and base64-encode for Help Scout."""
resp = requests.get(
f"{BASE_URL}/conversations/{conversation_id}/files/{file_id}/full",
headers=HEADERS
)
resp.raise_for_status()
content = resp.content
if len(content) > 10 * 1024 * 1024: # 10MB limit
return None, "FILE_TOO_LARGE"
return base64.b64encode(content).decode("utf-8"), None
# When building thread payloads, attach files:
def add_attachments_to_thread(thread_payload, conv_id, file_list, oversized_log):
attachments = []
for f in file_list:
encoded, error = download_and_encode_attachment(conv_id, f["id"])
if error:
oversized_log.append({"conv_id": conv_id, "file_id": f["id"],
"name": f["name"], "size": f["size"]})
continue
attachments.append({
"fileName": f["name"],
"mimeType": f.get("mimetype", "application/octet-stream"),
"data": encoded
})
if attachments:
thread_payload["attachments"] = attachmentsStep 7: Validate the Migration
After loading, run validation checks:
def validate_migration(expected_counts, hs_token):
"""Compare source and destination record counts."""
resp = requests.get(
f"{HS_BASE}/conversations?status=all&mailbox={TARGET_MAILBOX_ID}",
headers={"Authorization": f"Bearer {hs_token}"}
)
hs_data = resp.json()
hs_count = hs_data.get("page", {}).get("totalElements", 0)
results = {
"unthread_conversations": expected_counts["conversations"],
"helpscout_conversations": hs_count,
"delta": expected_counts["conversations"] - hs_count,
"match": expected_counts["conversations"] == hs_count
}
# Check for failed loads
with open(f"{OUTPUT_DIR}/load_log.json") as f:
load_log = json.load(f)
failed = [r for r in load_log if r["status"] == "failed"]
results["failed_count"] = len(failed)
results["failed_ids"] = [r["unthread_id"] for r in failed]
return resultsResolving Slack Connect External Participants
Unthread conversations in Slack Connect channels involve external users from other Slack workspaces. These participants present a specific identity resolution challenge:
- External Slack user IDs (e.g.,
U04ABC123from a partner workspace) cannot be resolved via your own Slack workspace'susers.infoAPI — you only get the display name and profile image visible in the shared channel. - Email addresses for external Slack Connect users are typically not accessible unless the external user's workspace admin has enabled email visibility for shared channels.
Mitigation approaches:
- Query Unthread's customer record for
emailDomains— if the external participant's domain is captured, construct an email from the Slack display name + domain. - Check if the external participant has an email stored in Unthread's customer
emailDomainsfield from prior interactions. - For participants with no resolvable email, generate
{slack-display-name}@{partner-domain}.placeholderand tag these recordsneeds-email-resolutionfor post-migration cleanup. - Store the original Slack user ID in a Help Scout contact note for future reference.
Knowledge Base Migration
Unthread knowledge base articles require additional handling beyond the basic mapping:
Article Content Conversion
Unthread KB articles are stored with markdown or plain text content. Help Scout Docs articles expect HTML. Apply the same Slack-markdown-to-HTML conversion function, plus handle any additional formatting (tables, images, embedded links).
Collection Structure
Help Scout Docs organizes articles into Collections (top-level) → Categories (optional sub-groupings) → Articles. Unthread's KB may not have an equivalent hierarchy. Your options:
- Single collection: Create one Help Scout Docs collection (e.g., "Knowledge Base") and import all articles flat.
- Tag-based collections: If Unthread articles are tagged or categorized, map each category to a Help Scout Docs collection.
- Manual organization: Import flat and reorganize in the Help Scout Docs editor post-migration.
Article Status
Unthread articles may have active/inactive states. Map active articles to "published" in Help Scout Docs; map inactive articles to "unpublished" (draft). Verify that article slugs/URLs are updated in any external links.
Docs API
Help Scout's Docs API (https://docsapi.helpscout.net/v1/) is separate from the Mailbox API. It uses API key authentication (not OAuth). Create articles via POST /articles with collectionId, name, text (HTML), and status fields.
Edge Cases and Challenges
Conversations with 100+ Messages
Help Scout enforces a hard limit of 100 threads per conversation. Unthread Slack threads can easily exceed this in active channels. Mitigation options:
- Truncate with note: Keep the most recent 99 messages and add a note at the top indicating truncation (shown in the transform code above).
- Split conversations: Create a "Part 1" conversation with the first 100 messages and a "Part 2" conversation with the remainder, linked by a note in each referencing the other's Help Scout ID.
- Summarize: For very long threads (500+), use the first message, last 98 messages, and a summary note.
Missing Email Addresses
Unthread customers may not have email addresses if they interact exclusively via Slack. Help Scout requires an email to create a customer. Mitigation: Generate placeholder emails (e.g., slack-{slackUserId}@migration.placeholder), tag these records needs-email-resolution for post-migration cleanup, and append the original Slack username and Slack user ID in an internal note on the customer profile.
Duplicate Customers
Unthread identifies customers primarily by Slack channel. Help Scout identifies customers by email, and email addresses must be globally unique across the Help Scout account. If a customer's Slack channel maps to multiple email addresses (or no email), you'll get duplicates or orphaned conversations. Mitigation: Build a canonical email lookup from Unthread's customer emailDomains field before migration. Apply deduplication rules: prefer company email over personal, prefer most recently active address. Test for uniqueness before loading. (developer.helpscout.com)
Private Notes vs. Public Messages
Unthread's isPrivateNote flag on messages maps directly to Help Scout's note thread type. But Unthread's triage thread messages (internal team discussion) should also become notes. Check the triageThreadTs field to identify these — any message with a non-null triageThreadTs was part of an internal triage thread and must be mapped to type: "note" to avoid exposing internal discussion to customers.
Attachments
Unthread stores file attachments with id, name, size, filetype, and mimetype on the conversation object. You cannot pass Unthread's authenticated file URLs directly to Help Scout. Your script must:
- Download each file via
GET /conversations/:id/files/:fileId/full - Base64-encode the content
- Embed it in the Help Scout thread payload under the
attachmentsarray
Help Scout limits attachments to 10MB per file. Do not silently drop oversized files. Log them to a quarantine list with conversation ID, file name, and size. Handle these manually post-migration (e.g., upload to cloud storage and link from a note).
Multi-Party Conversations
A shared Slack thread may have many external participants, but Help Scout ties the conversation to one primary customer. Mitigation: Set the conversation's primary customer to the original requester. Add additional participants as CC recipients where the Help Scout API supports it. For participants beyond the CC list, record them in an internal note: "Additional participants: name1@example.com, name2@example.com". (docs.helpscout.com)
Conditional Ticket Type Fields
Unthread supports conditional ticket type fields (fields that appear only when another field has a specific value). Help Scout custom fields don't support conditional logic. Mitigation: Flatten all conditional fields into the Help Scout mailbox. Fields that were conditionally hidden in Unthread will simply appear empty on conversations where their condition wasn't met.
Limitations and Constraints Summary
Help Scout limitations that affect this migration:
| Constraint | Limit | Impact |
|---|---|---|
| Threads per conversation | 100 max | Long Slack threads truncated |
| Custom fields per mailbox | 10 max | Complex ticket type schemas overflow |
| Custom field type | Single-select dropdowns only | Multi-select fields need workarounds |
| Priority field | None (native) | Must use custom field or tag |
| User creation via API | Not available | Must invite via UI or SCIM (Pro only) |
| Write request cost | 2x toward rate limit | Effective write throughput halved |
| OAuth token lifetime | 48 hours | Long migrations need token refresh |
| Attachment size | 10MB per file | Oversized files must be handled manually |
| Customer email uniqueness | Global | Deduplication required before load |
| Customer conversation lock | 5,000 conversations | Validate high-volume contacts first |
Unthread limitations that affect extraction:
| Constraint | Impact |
|---|---|
| API labeled Beta | Endpoint behavior may change without notice |
| No bulk export endpoint | Messages fetched per-conversation (N+1 pattern) |
| Rate limits undocumented | Must use conservative pacing with backoff |
| eventType enum undocumented | Thread type inference requires validation against actual data |
Validation and Testing
Sandbox Migration
Never migrate directly to production. Run a subset of data (50–100 conversations spanning different ticket types, statuses, and edge cases) into a Help Scout staging mailbox first.
Record Count Comparison
| Object | Unthread Count | Help Scout Count | Status |
|---|---|---|---|
| Customers | ✓ | ✓ | Match? |
| Conversations | ✓ | ✓ | Match? |
| Tags | ✓ | ✓ | Match? |
| KB Articles | ✓ | ✓ | Match? |
Field-Level Validation
Sample 5–10% of migrated conversations (minimum 50 records) and verify:
- Subject/title matches
- Status is correct
- Thread count matches (within the 100-thread limit)
- Thread types are correct (no internal notes exposed as customer-visible replies)
- Customer email is correctly linked
- Tags are applied
- Custom field values are preserved
- Attachments are accessible and render correctly
- Original creation dates are preserved (not stamped with migration date)
- Slack markdown is rendered as HTML (bold, links, code blocks display correctly)
UAT Process
- Agent review — have 2–3 agents verify conversations they remember from Unthread. Check that internal notes didn't become public replies.
- Edge case audit — specifically check conversations with 100+ messages, no customer email, multiple attachments, conditional ticket type fields, and Slack Connect external participants.
- Search validation — tag all imported records with
migrated-from-unthreadand use Help Scout search to inspect them. Example query:tag:migrated-from-unthread status:active. - Notification test — confirm that the
imported: trueflag prevented any customer-facing email notifications from being sent during the migration.
Rollback Procedure
If validation fails and you need to clean up Help Scout:
- Identify affected records: Query your load log for all Help Scout conversation IDs created during the migration run.
- Bulk delete via API: Use
DELETE /v2/conversations/{id}for each migrated conversation. At ~100 effective write ops/min (Standard plan), deleting 10K conversations takes ~100 minutes. - Delete customers: If you created new customers, delete them via
DELETE /v2/customers/{id}. Note: this permanently removes the customer and all associated data. - Remove custom fields and tags: Delete any custom fields and tags created specifically for the migration via the Help Scout UI or API.
- Re-run from staging data: Your staging database (JSONL files or SQLite) contains all extracted Unthread data. Fix the transformation logic, re-run the sandbox test, validate, then attempt the full load again.
- Partial rollback: If only a subset of conversations failed, use the load log to identify failed IDs. Re-transform only those records and retry the load without deleting successful records.
def rollback_migration(load_log_path, auth):
"""Delete all successfully migrated conversations from Help Scout."""
with open(load_log_path) as f:
load_log = json.load(f)
successful = [r for r in load_log if r["status"] == "success" and r.get("hs_id")]
print(f"Rolling back {len(successful)} conversations...")
for record in successful:
token = auth.get_token()
resp = requests.delete(
f"{HS_BASE}/conversations/{record['hs_id']}",
headers={"Authorization": f"Bearer {token}"}
)
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", 60)))
requests.delete(
f"{HS_BASE}/conversations/{record['hs_id']}",
headers={"Authorization": f"Bearer {auth.get_token()}"}
)
time.sleep(0.6) # respect rate limits (deletes count as 2)
print(f"Rollback complete. {len(successful)} conversations deleted.")Post-Migration Tasks
Once data is loaded and validated:
- Rebuild Workflows: Unthread Automations don't migrate. Recreate routing rules, auto-assignment, SLA warnings, and escalation logic as Help Scout Workflows. Document each Unthread automation and its Help Scout equivalent before the cutover.
- Configure Beacon: If replacing Unthread's in-app chat widget, set up Help Scout's Beacon for live chat and knowledge base search.
- Organize Knowledge Base: If you migrated KB articles, arrange them into Help Scout Docs collections and verify HTML formatting renders correctly.
- Update Integrations: Reconnect CRM (Salesforce, HubSpot), task managers, and other integrations. Point webhooks and API calls away from Unthread to Help Scout.
- Resolve Placeholder Emails: Query for customers tagged
needs-email-resolutionand update them with real email addresses. - Train the Team: Help Scout's shared inbox model differs from Unthread's Slack-native workflow. Cover collision detection, @mentions, conversation assignment, saved replies, and the distinction between notes and replies.
- Monitor for 2 Weeks: Watch for orphaned customers, missing attachments, and custom field mismatches. Run the validation script daily for the first week. Check that no customer-facing notifications were sent for imported conversations.
For a detailed post-migration validation framework, see our Post-Migration QA Checklist.
Best Practices
- Back up everything first. Export all Unthread data to JSONL files and store in a versioned location (S3, GCS) before loading anything into Help Scout.
- Use a staging database. Never pipe data directly from API to API. Store extracted data in a middle layer (PostgreSQL, SQLite, or local JSONL files) so you can retry failed payloads without re-querying Unthread.
- Run a test migration. Always load into a staging mailbox before touching production. Validate thread types, timestamps, attachments, and custom fields.
- Use the
imported: trueflag on every Help Scout conversation and thread to prevent reopening and suppress customer-facing email notifications. - Implement checkpointing. Write a progress file after each conversation is loaded so you can resume from where you left off if the script fails mid-run.
- Validate incrementally. Don't wait until the full load is complete to start checking. Validate in batches of 100–500 conversations.
- Map users before conversations. Create all Help Scout users via UI invite first. Build the user ID lookup table before loading conversations.
- Preserve source IDs. Store every Unthread object's original ID in Help Scout notes or tags (e.g.,
unthread:conv-abc123) for traceability and replay. - Validate eventType coverage. Before running the full migration, extract all unique
metadata.eventTypevalues from your Unthread messages and ensure every type has a correct thread-type mapping rule. - Log oversized attachments. Never silently drop files that exceed Help Scout's 10MB limit. Maintain a quarantine log for manual handling.
Sample Data Mapping Table
| # | Unthread Source | Unthread Field | Help Scout Target | Help Scout Field | Transform |
|---|---|---|---|---|---|
| 1 | Conversation | title |
Conversation | subject |
Direct; fallback "(no subject)" if null |
| 2 | Conversation | status |
Conversation | status |
Map: open/in_progress→active, on_hold→pending, closed→closed |
| 3 | Conversation | priority |
Custom Field | priority (dropdown) |
Map 3→Critical, 5→High, 7→Normal, 9→Low |
| 4 | Conversation | createdAt |
Conversation | createdAt |
Direct (ISO 8601); must be explicitly set |
| 5 | Conversation | closedAt |
Conversation | closedAt |
Direct |
| 6 | Conversation | assignedToUserId |
Conversation | assignee.id |
Lookup: Unthread ID → Help Scout user ID (matched by email) |
| 7 | Conversation | tags [].name |
Conversation | tags [] |
Direct; pre-create in mailbox |
| 8 | Conversation | ticketTypeFields |
Conversation | fields [] |
Map ticket type field ID → Help Scout custom field ID; respect 10-field limit |
| 9 | Customer | name |
Customer | firstName + lastName |
Split on last space; fallback to firstName only |
| 10 | Customer | emailDomains [0] |
Customer | email |
Use first domain-based email or generate placeholder |
| 11 | Account | name |
Company | name |
Direct |
| 12 | Account | emailsAndDomains |
Company | domains |
Lowercase, dedupe |
| 13 | Message | text |
Thread | text |
Convert Slack markdown to HTML |
| 14 | Message | metadata.eventType |
Thread | type |
Infer: CUSTOMER_EVENT_TYPES→customer, AGENT_EVENT_TYPES→reply, BOT_EVENT_TYPES→note |
| 15 | Message | isPrivateNote |
Thread | type |
true → note |
| 16 | Message | triageThreadTs |
Thread | type |
non-null → note |
| 17 | KB Article | title |
Docs Article | name |
Direct |
| 18 | KB Article | content |
Docs Article | text |
Convert markdown to HTML |
| 19 | KB Article | status |
Docs Article | status |
active→published, inactive→unpublished |
Automation Script Architecture
For teams building in-house, structure your Python or Node.js application into these modules:
- Auth Module: Manages Help Scout OAuth tokens with automatic refresh before the 48-hour expiry window (see
HelpScoutAuthclass above) and Unthread API key header injection. - Extractor Module: Paginates Unthread endpoints with cursor-based pagination at 100 records/page, handles retries with exponential backoff, and writes raw JSON to local storage (JSONL files per object type). Implements checkpointing to resume interrupted extractions.
- Transformer Module: Runs mapping logic — status translation, thread type inference from
eventType/isPrivateNote/triageThreadTs, Slack markdown to HTML conversion, Slack user ID to email resolution, custom field mapping with overflow handling, and Help Scout payload construction. - Loader Module: Batches requests to Help Scout respecting plan-specific rate limits, handles 429 retries using the
Retry-Afterheader, refreshes OAuth tokens automatically, and writes success/failure records to a load log for reconciliation. - Attachment Module: Downloads binary files from Unthread, validates size against Help Scout's 10MB limit, base64-encodes, and embeds in thread payloads. Maintains a quarantine log for oversized files.
- Validator Module: Compares record counts between source and destination, samples conversations for field-level accuracy (thread types, timestamps, tags, custom fields, attachments), and generates a reconciliation report with pass/fail per object type.
Summary
Migrating from Unthread to Help Scout is a platform paradigm shift — from Slack-native, AI-driven ticketing to email-first shared inbox. The technical execution is API-to-API with no shortcuts. The risk concentrates in three areas:
- Thread-type inference: Unthread messages lack an explicit type field. Incorrect inference exposes internal notes to customers or hides customer messages as notes. Validate your eventType mapping against actual data.
- Customer identity resolution: Slack IDs must be resolved to email addresses before any conversation can be attributed. Slack Connect external participants add another layer of complexity.
- Help Scout structural limits: 100 threads per conversation, single-select-only custom fields, 10 custom fields per mailbox, no custom objects, no native priority field, write requests counting double against rate limits.
Get the data mapping right, run a sandbox migration, validate thread types and timestamps aggressively, and build a tested rollback procedure before touching production.
For related guidance, check out our Help Scout Migration Checklist and How to Export Data from Unthread.
Frequently Asked Questions
- Can I migrate Unthread conversations to Help Scout without coding?
- Not with full fidelity. Third-party tools like Help Desk Migration offer a no-code wizard, but Unthread is not on Help Scout's published supported-source list for its Import2-powered import flow. For full conversation history with attachments and thread structure, an API-based migration or managed service is required.
- What is the Help Scout API rate limit during a migration?
- Help Scout enforces plan-dependent rate limits — up to 200/400/800 requests per minute on Standard/Plus/Pro plans. Write requests (POST, PUT, DELETE, PATCH) count as 2 requests toward the limit. Exceeding the limit returns HTTP 429 with a Retry-After header.
- Does Help Scout have a thread limit per conversation?
- Yes. Help Scout enforces a hard limit of 100 threads per conversation. If an Unthread conversation has more than 100 messages, you need to truncate or split it into multiple Help Scout conversations.
- How do I handle Unthread customers with no email when migrating to Help Scout?
- Help Scout requires an email address for every customer. For Unthread customers who only interact via Slack, generate a placeholder email (e.g., slack-user-{slackId}@migration.placeholder), tag these records, and resolve the emails post-migration.
- How long does an Unthread to Help Scout migration take?
- Depends on volume and complexity. A small dataset under 5K conversations typically takes 1–3 days with a tested API script. Larger datasets (50K+) with attachments and custom fields can take 1–2 weeks including extraction, transformation, loading, and validation.


