---
title: "Freshchat to Unthread Migration: The CTO's Technical Guide"
slug: freshchat-to-unthread-migration-the-ctos-technical-guide
date: 2026-07-22
author: Roopi
categories: [Migration Guide, Unthread]
excerpt: "Technical guide for migrating from Freshchat to Unthread. Covers API extraction, data model mapping, field transformations, edge cases, and validation."
tldr: "Freshchat to Unthread is a schema translation project, not a simple export/import. Use API-led ETL or a managed service when history, attachments, and routing matter."
canonical: https://clonepartner.com/blog/freshchat-to-unthread-migration-the-ctos-technical-guide/
---

# Freshchat to Unthread Migration: The CTO's Technical Guide


# Freshchat to Unthread Migration: The CTO's Technical Guide

Migrating from Freshchat to Unthread means moving from a traditional omnichannel messaging platform into a Slack-native, conversational ticketing system. This is a schema translation project, not a simple export/import. Freshchat's data model is built around users, conversations, messages, agents, groups, and channels. Unthread's model is built around conversations, customers/accounts, Slack-synced users, ticket types, and channel-based intake across Slack, email, and in-app chat. ([developers.freshchat.com](https://developers.freshchat.com/api/))

There is no documented push-button historical import path from Freshchat into Unthread. Unthread's native backfill covers Slack channel history only (with a 6-month backfill option), and its email connector does not ingest historical email activity to create tickets. For Freshchat history, plan on API replay or a managed migration service. ([docs.unthread.io](https://docs.unthread.io/docs/setting-up-your-account/connecting-customer-channels?utm_source=openai))

This guide covers the full technical path: data model mapping, API extraction, transformation logic, edge cases, import strategies, compliance considerations, and empirical timing benchmarks.

## Why Teams Move from Freshchat to Unthread

**Freshchat** is Freshworks' messaging product for support conversations spanning web chat, mobile, WhatsApp, Facebook Messenger, email, and more. It is built for external-facing sales and support. Freshchat pricing starts at $19/agent/month (Free plan with limited features) through $79/agent/month (Enterprise), though exact tiers depend on whether it is purchased standalone or as part of the Freshworks Customer Service Suite. ([developers.freshchat.com](https://developers.freshchat.com/api/))

**Unthread** is a Slack-native AI helpdesk that converts Slack conversations into tracked, routed, and resolved tickets. It handles B2B customer support via shared Slack Connect channels, internal support across IT, HR, Legal, and Finance teams, plus email and web chat intake.

The most common migration drivers:

- **Slack-first workflow.** Teams already working in Slack want ticketing without forcing users into a separate portal. Freshchat requires agents to work in a dedicated dashboard; Unthread keeps everything in Slack.
- **Simplicity over feature sprawl.** Unthread's focused architecture reduces admin overhead compared to Freshworks' broader suite (Freshdesk, Freshsales, Freshservice, etc.).
- **AI-native resolution.** Unthread's AI agent can auto-resolve tickets directly from Slack using connected knowledge base sources, without separate bot configuration or workflow builder setup.
- **Cost and licensing model.** Unthread's published pricing is $50/agent/month (Basic) and $75/agent/month (Pro), with AI included in plans rather than sold as add-ons. For teams with 10+ agents, the per-seat cost difference relative to Freshchat Enterprise ($79/agent/month) narrows, but the elimination of separate AI/bot add-on costs is the meaningful delta.

If outbound or lifecycle messaging is part of the business case, validate that separately. Freshchat documents outbound messaging APIs with campaign support, while Unthread offers broadcasts and mass email but not a full marketing automation suite. ([docs.unthread.io](https://docs.unthread.io/docs/global-workspace-settings/accounts))

> [!WARNING]
> This migration involves a fundamental architecture shift. Freshchat is channel-agnostic with a traditional contact/conversation model. Unthread is Slack-native with a Customer-as-Channel model. Not every Freshchat concept has a 1:1 equivalent. Plan accordingly.

## Data Model Differences: Freshchat vs Unthread

Before mapping fields, understand how differently these platforms structure data:

| Concept | Freshchat | Unthread |
|---|---|---|
| End users | Contacts (standalone records with email, phone, properties) | Users (synced from Slack workspace) or external contacts on Customers |
| Companies | Accounts / Companies | Accounts (with emailsAndDomains, slackChannelIds) |
| Support agents | Agents (separate `/v2/agents` endpoint, distinct from contacts) | Users (members of Slack workspace, no separate agent object) |
| Conversations | Conversations with messages, multi-channel (web, WhatsApp, Messenger, email) | Conversations tied to Slack threads/channels, email, or triage |
| Ticket categories | Labels, Groups, Topics | Tags, Ticket Types with custom fields |
| Custom fields | Conversation properties (`cf_*` prefixed), contact properties | Ticket Type Fields (short-answer, single-select, multi-select, checkbox, user-select) |
| Knowledge base | Freshchat FAQ / Freshdesk KB integration | Knowledge Base Articles with user group scoping |
| Automations | Assignment rules, IntelliAssign, bot flows (Freddy) | Automations (event-triggered, webhook, scheduled, manual) |
| CSAT | Built-in CSAT surveys with configurable triggers | CSAT surveys configurable per Customer or globally |

The most consequential difference: Freshchat's Contact/Agent object split means `/v2/contacts` returns only end-user contacts, not agents. Agents are a separate entity at `/v2/agents`. Unthread's User model encompasses everyone in the Slack workspace — there is no agent/contact distinction. Customers in Unthread represent external companies or channel-level groupings, not individual people.

Freshchat conversation status values are `new`, `assigned`, `resolved`, and `reopened`. Unthread uses `open`, `in_progress`, `on_hold`, and `closed`. Status mapping, custom-field mapping, and any CRM-adjacent data all need explicit transformation rules. ([developers.freshchat.com](https://developers.freshchat.com/api/))

## Define Your Migration Scope

Separate what moves as data, what gets rebuilt as configuration, and what gets archived.

**Migrate via API:**
- Conversations with full message history
- Contacts → mapped to Unthread external users or linked to Customers
- Companies/Accounts → Unthread Accounts
- Tags and labels
- Knowledge base articles
- File attachments (within Unthread's 20MB per-file limit)

**Rebuild manually:**
- Agent assignments and team structures
- SLA policies and escalation rules
- Automations and bot flows
- Routing rules and IntelliAssign logic
- CSAT survey configuration
- Channel integrations (WhatsApp, Messenger, Instagram — Unthread focuses on Slack, email, web chat, and Teams)
- Canned responses / macros

**Archive before migration:**
- Analytics and reporting dashboards
- Historical SLA metrics
- Channel-specific data from WhatsApp, Messenger, or Instagram that has no Unthread equivalent
- Freshsales or CRM-adjacent data (accounts, leads, opportunities, activities, custom objects) — keep these in the CRM or migrate them as a separate workstream

## Migration Approaches

### 1. API-Based Migration (Recommended)

Extract data from Freshchat's REST API v2 and load it into Unthread's API.

**How it works:** Authenticate to Freshchat using Bearer API tokens from Admin > API Tokens. Paginate through `/v2/contacts`, `/v2/conversations`, and `/v2/agents` endpoints. For each conversation, fetch messages via `/v2/conversations/{conversation_id}/messages`. Transform data to match Unthread's schema. Create Customers, Conversations, Messages, Tags, and KB articles via Unthread's API using an `X-Api-Key` header. ([developers.freshchat.com](https://developers.freshchat.com/api/))

**When to use:** Any migration where you need message-level fidelity and relationship preservation.

**Pros:** Full control over data mapping and transformation. Preserves message threading and metadata. Supports delta sync for cutover.

**Cons:** Requires engineering effort (estimate 2–4 weeks for a team of one, depending on data volume and edge case density). Rate limits on both sides slow large exports. Public Unthread schemas do not document backdated message timestamps. ([docs.unthread.io](https://docs.unthread.io/docs/api-docs/api-reference))

**Complexity:** High.

**Empirical timing benchmarks:** At Freshchat's ~100 requests/minute rate limit, extracting 50,000 conversations with messages takes approximately 15–20 hours of API time (conversations list + per-conversation message fetches). Unthread loading time depends on undocumented rate limits — plan conservatively for 30–50 requests/minute until confirmed with Unthread support. A 50K-conversation migration typically takes 1–2 weeks end-to-end including extraction, transformation, loading, validation, and one re-run.

### 2. CSV Export + API Import (Hybrid)

**How it works:** Export contacts from Freshchat as CSV/XLSX (5MB file size limit per import). Parse and transform the CSV for names, emails, and company associations. Use Unthread's `POST /customers` and `POST /accounts` endpoints for the contact portion. Conversations still require API extraction — no native bulk conversation export to CSV exists with full message fidelity.

Freshchat's raw report export is constrained: report start dates cannot be earlier than 15 months from the current date, the end date cannot be more than one month after the start date, and the `Chat-Transcript` report is limited to 24-hour windows. This means extracting 15 months of chat transcripts requires at least 450 individual report requests. ([developers.freshworks.com](https://developers.freshworks.com/assets/Freshchat-ReportsAPI.pdf))

**When to use:** Smaller accounts (under 5,000 conversations) where contact migration is the priority and conversation history is secondary.

**Pros:** Lower effort for the contact portion.

**Cons:** Conversations still require API work. CSV exports flatten chat transcripts into single description blocks — individual messages, their senders, and their timestamps are lost unless you parse them with regex or structured extraction. No native Unthread CSV import for conversations.

**Complexity:** Medium.

### 3. Custom ETL Pipeline

**How it works:** Stage raw Freshchat payloads in a database (PostgreSQL, SQLite) or object store (S3), normalize them into a canonical schema, build crosswalk tables mapping Freshchat IDs → Unthread IDs, load Unthread in batches with checkpoint logging, validate counts and checksums, and run a delta phase before cutover. Unlike a simple API script, this is built for re-runs, auditability, and rollback. ([developers.freshchat.com](https://developers.freshchat.com/api/))

**When to use:** Enterprise volumes (100K+ conversations), regulated teams (HIPAA, SOC 2, GDPR), multi-brand environments, or any migration tied to Freshsales or another CRM.

**Pros:** Best validation coverage, safest reruns, clean rollback posture, supports coexistence period, produces audit trail.

**Cons:** Highest upfront effort (3–6 weeks for a dedicated engineer). Overkill for standard helpdesk migrations under 20K conversations.

**Complexity:** High.

### 4. Middleware Platforms (Zapier, Make, Pipedream)

**How it works:** Configure triggers and actions between Freshchat and Unthread. Pipedream has pre-built components for both platforms. Unthread officially documents Zapier integration and webhook subscriptions. ([docs.unthread.io](https://docs.unthread.io/docs/integrations/zapier?utm_source=openai))

**When to use:** Ongoing sync of new conversations during a transition period where both systems run in parallel. Not a serious option for replaying years of chat history — Zapier's task limits (e.g., 750 tasks/month on the Free plan, 2,000 on Starter) make bulk migration cost-prohibitive.

**Pros:** No custom code for simple flows. Good for real-time forwarding during a 1–2 week cutover window.

**Cons:** Not designed for bulk historical migration. Pagination and backfill logic is manual. Rate limits compound across both APIs. Weak batching and fragile retry logic. Zapier does not natively support cursor-based pagination for Freshchat's conversation list endpoint.

**Complexity:** Low (for sync), Medium (for backfill).

### 5. Managed Migration Service

**How it works:** A team that has done this before handles extraction, transformation, loading, and validation.

**When to use:** When engineering bandwidth is limited, data volumes are large, or the risk of data loss during a DIY migration is unacceptable.

**Complexity:** Low (for your team).

### Approach Comparison

| Method | Best For | Historical Data | Ongoing Sync | Complexity | Main Risk |
|---|---|---|---|---|---|
| Direct API | Most real migrations | ✅ Full | Possible via delta | High | Build effort, rate limits |
| CSV + API Hybrid | Small accounts, contact-heavy | ⚠️ Partial (messages lost) | ❌ One-time | Medium | Loses message fidelity |
| Custom ETL | Enterprise, regulated, multi-system | ✅ Full | ✅ Best | High | Time to build |
| Middleware | Parallel run, data forwarding | ❌ Limited | ✅ Yes | Low–Medium | Brittle at volume, task limits |
| Managed Service | Enterprise, risk-sensitive | ✅ Full | ✅ Optional | Low (your side) | Vendor selection |

**Recommended method by scenario:**

- **Small business (<5K conversations):** Move only open and recently active conversations. Direct API or a managed vendor.
- **Mid-market (5K–50K conversations):** Direct API with crosswalk table. Budget 2–3 weeks of engineering time.
- **Enterprise (50K+ conversations):** Custom ETL or a managed engineering service with staging, reruns, and a delta phase.
- **Low engineering bandwidth:** Buy the migration, but insist on a demo with attachments, edge cases, and UAT support.

## Pre-Migration Planning

Audit two layers separately. **Freshchat proper** gives you users, conversations, messages, agents, groups, channels, files, and reports. **Freshsales-side or adjacent CRM data** is different scope — Freshchat conversation properties are only documented for built-in Freshsales Suite chat accounts, which signals that CRM-adjacent context may need a second extraction path. ([developers.freshchat.com](https://developers.freshchat.com/api/))

Use this checklist before you write a script:

- **Data audit:** Export a baseline count of Freshchat Contacts, Conversations, Messages, Agents, Groups, Tags, and KB articles. Identify stale or duplicate records. Record these counts — you will need them for post-migration validation.
- **Source-side custom data:** Conversation `cf_*` fields, priority usage, bot payloads (quick replies, carousels, input widgets), macros or canned response templates.
- **Adjacent CRM data:** Accounts, contacts, leads, opportunities, activities, custom objects (if Freshsales is in play).
- **Migration scope:** All history, recent history (e.g., last 12 months), open only, or open plus top accounts. Do not migrate empty conversations, spam tickets, or inactive users from five years ago. For reference: most teams find that 60–80% of their Freshchat conversations are resolved and older than 12 months. Migrating only open + recent-12-months typically cuts data volume by 70%.
- **Strategy:** Big bang vs. phased (historical load followed by delta sync). A phased approach is strongly recommended — run both systems in parallel for 1–2 weeks, sync new conversations via webhook or middleware, then cut over once validation passes.
- **Risk plan:** Retain read-only access to Freshchat for 30–90 days post-migration. Assign a rollback owner and a UAT owner.
- **GDPR and data residency:** Freshchat data may reside in US, EU, or India data centers depending on your account. Staging extracted data in a local database or object store means PII (emails, names, phone numbers, message content) is temporarily stored outside both platforms. Encrypt staged data at rest (AES-256) and in transit (TLS 1.2+). If your organization operates under GDPR, document the lawful basis for processing during migration (typically legitimate interest), ensure staged data is deleted within 30 days of migration completion, and verify that both Freshchat and Unthread data residency regions are compatible with your data processing agreements.
- **Freshchat API version:** Freshchat's REST API v2 is the current stable version as of 2024. v1 endpoints are deprecated. Confirm your account supports v2 by testing a `/v2/conversations` call before building your extraction pipeline.
- **Set up Unthread first:** Create your Slack workspace connection, Projects, Ticket Types, Tags, and Accounts before importing any data. These scaffolding records must exist before conversations can reference them.

## Field-Level Data Mapping

| Freshchat Field | Unthread Field | Notes |
|---|---|---|
| `contact.email` | `user.email` / Customer `emailDomains` | External contacts map to Customer email domains. Primary identity key for deduplication. |
| `contact.first_name` + `last_name` | `user.name` / Customer `name` | Concatenate with space delimiter. Handle null `last_name` gracefully. |
| `contact.phone` | User `customAttributes` | No native phone field on Unthread User. Store as custom attribute or in notes. |
| `conversation.conversation_id` | Conversation metadata or notes | Unthread generates its own UUIDs. Store legacy ID in metadata for traceability. |
| `conversation.status` | `conversation.status` | Map: `new` → `open`, `assigned` → `in_progress`, `resolved` → `closed`, `reopened` → `open`. Note: Unthread also supports `on_hold`, which has no Freshchat equivalent. |
| `conversation.assigned_agent_id` | `conversation.assignedToUserId` | Must resolve Freshchat agent ID → Unthread user ID via crosswalk table. Only map if target assignees exist in the Slack workspace. |
| `conversation.label` | `tag.name` | Create tags first via `POST /tags`, then link via `POST /tags/:tagId/conversations/create-links`. |
| `conversation.custom_fields` | `conversation.ticketTypeFields` | Requires Ticket Type setup with matching field IDs. Map `cf_*` keys to Ticket Type Field UUIDs. |
| `conversation.priority` | `conversation.priority` | Freshchat uses 1–4 (1=urgent). Verify Unthread's priority values match or remap. |
| `message.message_type` (normal/private) | `message.isPrivateNote` | Map `private` → `isPrivateNote: true`. |
| `message.message_parts` | `message.body` / `message.markdown` | Convert HTML to Markdown. Flatten bot quick replies and carousels into readable text with `[Quick Reply: option_text]` notation. |
| `message.created_time` | `message.ts` | See timestamp warning below. |
| `message.actor_id` + `actor_type` | Message sender identification | Resolve `actor_type: "agent"` via agent crosswalk, `actor_type: "user"` via contact crosswalk. |
| `channel_id` / topic | `projectId` + `channelId` or `emailInboxId` | Depends on whether target intake is Slack or email. Map Freshchat channels to Unthread Projects. |
| `account.name` | `account.name` | Direct mapping. |
| `account.domains` | `account.emailsAndDomains` | Freshchat may store domains differently; normalize to array format. |
| FAQ article title/body | KB article `title`/`content` | Use `POST /knowledge-base/articles`. Convert HTML content to Markdown. |

> [!CAUTION]
> **Timestamp warning:** If original message timestamps must appear as native Unthread timestamps, prove that in a pilot first. Unthread's public create and update schemas document status, assignee, customer, notes, ticket type, metadata, and attachments, but they do not document backdated `createdAt` fields. Contact Unthread support to ask whether undocumented parameters exist for setting creation timestamps on imported conversations. In most migrations, the original Freshchat timestamp is preserved in metadata (e.g., `metadata.originalCreatedAt`) or as an inline prefix in transcript text (e.g., `[2024-01-15 14:32 UTC] Original message...`). ([docs.unthread.io](https://docs.unthread.io/docs/api-docs/api-reference))

## Step-by-Step Migration Process

### Step 1: Extract Data from Freshchat

Authenticate with Bearer tokens and paginate through the core endpoints. Freshchat's API rate limit is approximately 100 requests per minute (plan-dependent), with HTTP 429 responses when exceeded. The Messages API caps at 50 items per page. The Conversations API supports up to 50 items per page as well.

```python
import requests
import time
import json
import os

FRESHCHAT_API_URL = "https://api.freshchat.com/v2"
FRESHCHAT_TOKEN = "your-bearer-token"
STAGING_DIR = "./freshchat_export"

def fetch_with_retry(url, headers, max_retries=5):
    """Fetch with exponential backoff on 429 and 5xx errors."""
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers)
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 2 ** attempt * 10))
            print(f"Rate limited. Waiting {wait}s (attempt {attempt+1})")
            time.sleep(wait)
            continue
        if resp.status_code >= 500:
            time.sleep(2 ** attempt * 5)
            continue
        resp.raise_for_status()
        return resp.json()
    raise Exception(f"Max retries exceeded for {url}")

def fetch_all_contacts():
    contacts = []
    url = f"{FRESHCHAT_API_URL}/contacts?items_per_page=100"
    headers = {"Authorization": f"Bearer {FRESHCHAT_TOKEN}"}
    while url:
        data = fetch_with_retry(url, headers)
        batch = data.get("contacts", [])
        contacts.extend(batch)
        url = data.get("pagination", {}).get("next_link")
        print(f"Fetched {len(contacts)} contacts...")
    return contacts

def fetch_all_conversations():
    conversations = []
    url = f"{FRESHCHAT_API_URL}/conversations?items_per_page=50"
    headers = {"Authorization": f"Bearer {FRESHCHAT_TOKEN}"}
    while url:
        data = fetch_with_retry(url, headers)
        batch = data.get("conversations", [])
        conversations.extend(batch)
        url = data.get("pagination", {}).get("next_link")
        print(f"Fetched {len(conversations)} conversations...")
    return conversations

def fetch_messages(conversation_id):
    messages = []
    url = f"{FRESHCHAT_API_URL}/conversations/{conversation_id}/messages?items_per_page=50"
    headers = {"Authorization": f"Bearer {FRESHCHAT_TOKEN}"}
    while url:
        data = fetch_with_retry(url, headers)
        batch = data.get("messages", [])
        messages.extend(batch)
        url = data.get("pagination", {}).get("next_link")
    return messages

def stage_data(data, filename):
    """Write extracted data to local JSON for transformation."""
    os.makedirs(STAGING_DIR, exist_ok=True)
    with open(os.path.join(STAGING_DIR, filename), 'w') as f:
        json.dump(data, f, indent=2)
```

Pull Contacts, then Agents (via `/v2/agents`), then Conversations, then Messages for each conversation. Store everything locally — JSON files or a staging database — before attempting any transformation. This separation ensures you can re-run transformation and loading without re-extracting from Freshchat.

> [!NOTE]
> Freshchat uses `page` and `items_per_page` pagination parameters for some endpoints and cursor-based `next_link` for others. Always use the `pagination.next_link` URL from the response when available — do not construct pagination URLs manually. For conversations, filter by `updated_since` (ISO 8601 timestamp) to limit extraction to your migration scope window.

### Step 2: Transform Data

Map statuses, concatenate names, resolve agent IDs to Unthread user IDs, flatten bot payloads into plain text, and restructure custom fields into Ticket Type Field format.

```python
import re
from html2text import html2text  # pip install html2text

STATUS_MAP = {
    "new": "open",
    "assigned": "in_progress",
    "resolved": "closed",
    "reopened": "open"
}

def flatten_message_parts(message_parts):
    """Convert Freshchat message_parts array to Markdown text."""
    texts = []
    for part in message_parts:
        if "text" in part:
            content = part["text"].get("content", "")
            # Convert HTML to Markdown
            texts.append(html2text(content).strip())
        elif "quick_reply_options" in part:
            # Flatten quick replies into readable text
            options = part["quick_reply_options"]
            option_texts = [f"[Quick Reply: {o.get('label', '')}]" for o in options]
            texts.append("Options presented: " + ", ".join(option_texts))
        elif "image" in part:
            url = part["image"].get("url", "")
            texts.append(f"[Attachment: {url}]")
        elif "file" in part:
            url = part["file"].get("url", "")
            name = part["file"].get("name", "file")
            texts.append(f"[File: {name}]({url})")
    return "\n\n".join(texts)

def transform_conversation(fc_conv, fc_messages, agent_map, contact_map):
    """Transform a Freshchat conversation + messages into Unthread format."""
    # Build message bodies
    first_message_body = ""
    if fc_messages:
        first_message_body = flatten_message_parts(
            fc_messages[0].get("message_parts", [])
        )

    return {
        "type": "triage",  # or "email" / "slack" depending on target
        "markdown": first_message_body,
        "status": STATUS_MAP.get(fc_conv.get("status"), "open"),
        "assignedToUserId": agent_map.get(fc_conv.get("assigned_agent_id")),
        "title": fc_conv.get("subject") or f"Migrated: {fc_conv['conversation_id'][:8]}",
        "excludeAnalytics": True,  # Prevent migrated data from skewing metrics
        "metadata": {
            "freshchat_conversation_id": fc_conv["conversation_id"],
            "freshchat_created_at": fc_conv.get("created_time"),
            "freshchat_channel": fc_conv.get("channel_id"),
            "migration_source": "freshchat",
            "migration_date": "2024-01-15"
        }
    }
```

Strip unsupported HTML from Freshchat messages and convert to Markdown. Freshchat quick replies, carousels, and attachment input constructs do not have a clean Unthread equivalent — flatten them into readable transcript text with bracket notation for auditability. ([developers.freshchat.com](https://developers.freshchat.com/api/))

Unthread supports three conversation types: `slack`, `triage`, and `email`. There is no generic "imported" type. Use `triage` as the default for migrated conversations — it does not require a Slack thread or email thread to exist.

### Step 3: Load into Unthread

Create records in dependency order: **Tags → Ticket Types → Accounts → Customers → Conversations → Messages → Knowledge Base articles.**

This ordering matters because conversations reference Customers, Ticket Types, and Tags by ID. Creating them out of order produces orphaned references or API errors.

```python
import sqlite3

UNTHREAD_API_URL = "https://api.unthread.io/api"
UNTHREAD_API_KEY = "your-api-key"

# Crosswalk database for idempotent reruns
db = sqlite3.connect("migration_crosswalk.db")
db.execute("""CREATE TABLE IF NOT EXISTS crosswalk (
    entity_type TEXT,
    source_id TEXT,
    target_id TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (entity_type, source_id)
)""")

def already_migrated(entity_type, source_id):
    """Check if a record was already migrated (idempotency)."""
    row = db.execute(
        "SELECT target_id FROM crosswalk WHERE entity_type=? AND source_id=?",
        (entity_type, source_id)
    ).fetchone()
    return row[0] if row else None

def record_migration(entity_type, source_id, target_id):
    """Record a successful migration for reruns."""
    db.execute(
        "INSERT OR REPLACE INTO crosswalk VALUES (?, ?, ?, CURRENT_TIMESTAMP)",
        (entity_type, source_id, target_id)
    )
    db.commit()

def create_unthread_conversation(payload, source_id):
    # Skip if already migrated
    existing = already_migrated("conversation", source_id)
    if existing:
        return {"id": existing}

    resp = requests.post(
        f"{UNTHREAD_API_URL}/conversations",
        json=payload,
        headers={"X-Api-Key": UNTHREAD_API_KEY, "Content-Type": "application/json"}
    )
    if resp.status_code == 429:
        time.sleep(30)  # Conservative backoff; adjust based on observed limits
        return create_unthread_conversation(payload, source_id)
    if resp.status_code not in (200, 201):
        # Quarantine bad records instead of stopping the run
        log_error("conversation", source_id, resp.status_code, resp.text, payload)
        return None
    result = resp.json()
    record_migration("conversation", source_id, result.get("id"))
    return result
```

Unthread has **no bulk import endpoint**. Every conversation and message must be created individually via the API. The crosswalk database (SQLite in this example, Redis or PostgreSQL for larger migrations) ensures that if your script crashes at record 47,000 of 100,000, you can restart without duplicating the first 47,000 records.

> [!NOTE]
> **Unthread rate limits:** Unthread's public API documentation does not publish a numeric rate limit as of early 2025. In practice, sustained throughput of 30–60 requests/minute has been observed as stable for conversation creation. Before running a full migration, contact Unthread support to confirm rate ceilings and request temporary limit increases for bulk imports. ([docs.unthread.io](https://docs.unthread.io/docs/api-docs/api-reference))

### Step 4: Rebuild Relationships

After conversations are created, link tags using `POST /tags/:tagId/conversations/create-links` with arrays of conversation IDs (batch these — the endpoint accepts arrays). Associate conversations with Customers by setting the `customerId` field during creation or via `PATCH /conversations/:id`. Verify Account associations by querying `GET /accounts` and cross-referencing your crosswalk table.

### Step 5: Validate

Compare record counts between source and target. Spot-check field values and verify message ordering within conversations. Details in the validation section below.

## Edge Cases and Challenges

- **Timestamp fidelity.** Conversations created via the Unthread API carry their creation timestamp, not the original Freshchat timestamp. Use `excludeAnalytics: true` to prevent historical data from distorting Unthread's reporting. Preserve original timestamps in metadata (`metadata.freshchat_created_at`) or as inline prefixes in transcript text. If your use case requires exact timestamp preservation (e.g., legal or compliance reasons), escalate to Unthread support before starting the migration.

- **Attachment handling.** Freshchat attachment URLs are signed and may expire after 24–72 hours. Download all files locally during extraction (Step 1), not during loading. Re-upload through Unthread's multipart upload endpoints. Unthread enforces a **20MB max file size** and a **10-file limit per Slack message**. Split attachments across multiple messages if needed. Track attachment download failures separately — these are the most common silent data loss. ([developers.freshchat.com](https://developers.freshchat.com/api/))

- **Chat transcripts as single blocks.** Freshchat CSV exports flatten entire chat transcripts into one description field with HTML formatting. You must parse these into individual messages (regex on timestamp patterns like `[HH:MM]` or `<div class="message">`) before importing, or accept losing per-message granularity.

- **Slack ID resolution.** If you migrate a Freshchat user without a matching Slack ID, Unthread treats them as an email-based user. If they later join your Slack Connect channel, you risk creating duplicate profiles. Plan your identity resolution strategy before you start loading data: build a lookup table of `email → Slack user ID` from your Slack workspace before migration.

- **Multi-channel data loss.** Freshchat conversations from WhatsApp, Instagram, or Messenger have no native Unthread equivalent. Import these as `triage`-type conversations with the original channel noted in metadata (`metadata.original_channel: "whatsapp"`). Alternatively, archive them as read-only JSON exports outside Unthread.

- **Customer model mismatch.** Freshchat contacts are standalone records with email and phone. Unthread Customers are tied to Slack channels via `slackChannelIds` or email domains via `emailsAndDomains`. If you do not have shared Slack channels for every Freshchat contact, create `triage`-type or `email`-type conversations and associate customers via email domain matching.

- **Bot payloads.** Freshchat quick replies, carousels, and attachment input constructs do not have a clean 1:1 Unthread equivalent. Flatten them into readable transcript text with bracket notation: `[Quick Reply: option_text]`, `[Carousel: card_title]`. This preserves the information without breaking Unthread's message parser. ([developers.freshchat.com](https://developers.freshchat.com/api/))

- **Duplicate identities.** One customer may exist as multiple Freshchat users with shared email domains or missing emails. Neither platform provides built-in deduplication during import. Build email-based matching logic into your transformation layer: normalize emails to lowercase, strip plus-addressing (e.g., `user+tag@domain.com` → `user@domain.com`), and merge contacts sharing the same normalized email.

- **Assignment and routing mismatch.** Freshchat's group/agent model and complex routing rules (including IntelliAssign's skill-based and load-balanced routing) do not drop cleanly into Unthread's workspace model. Document your current routing logic before migration, then rebuild using Unthread's automation engine with event-triggered rules.

- **Freshchat report constraints.** Native raw-report export is not a complete historical extraction strategy: start dates cannot be earlier than 15 months from current, end dates cannot exceed one month after start, and the Chat-Transcript report is limited to 24-hour windows. Use the API for extraction instead. ([developers.freshworks.com](https://developers.freshworks.com/assets/Freshchat-ReportsAPI.pdf))

- **Unthread rate limits.** Unthread's public API docs do not publish a numeric rate limit. Empirically, 30–60 requests/minute for conversation creation has been stable. For enterprise backfills (50K+ records), throttle conservatively, implement exponential backoff on any non-2xx response, and confirm ceilings with Unthread support before running the full job. ([docs.unthread.io](https://docs.unthread.io/docs/api-docs/api-reference))

- **Bulk deletion for rollback.** Unthread's API documents individual `DELETE /conversations/:id` but does not document a bulk deletion endpoint. If you need to roll back a failed import, script individual deletions using your crosswalk table. At 30–60 requests/minute, deleting 50K records takes 14–28 hours. Factor this into your rollback plan.

- **Multi-workspace and multi-brand.** If you run multiple Freshchat accounts (e.g., per brand or per region), each maps to a separate Unthread workspace or separate Projects within one workspace. Unthread's Project model supports this, but each Project needs its own Slack channel. Plan your Slack channel structure before migration.

> [!WARNING]
> **State management is non-negotiable.** Do not attempt a production migration without a local state database (SQLite for small migrations, PostgreSQL for enterprise). If your script crashes halfway through 100,000 records, you need a reliable way to resume without duplicating tickets. Make every step idempotent by checking the crosswalk table before creating any record. Quarantine bad records (log them to a `failed_records` table) instead of stopping the whole run.

## Validation and Testing

Never assume a 200 OK response means the data is correct. Validate at three levels:

1. **Record count comparison.** Total contacts, conversations, messages, and attachments in Freshchat vs. created records in Unthread. Every number should match or have a documented reason for the discrepancy (e.g., "47 conversations excluded: 32 spam, 15 empty"). Build a validation report that shows: source count, target count, delta, and explanation for each entity type.

2. **Field-level spot checks.** Sample 5–10% of records (minimum 50 records) and verify:
   - Status values mapped correctly
   - Assignees resolved to correct Unthread users
   - Customer/Account associations are correct
   - Message ordering within conversations matches chronological order
   - Custom fields populated with correct values
   - Tags linked to correct conversations
   - Attachments accessible (not broken links)
   - Original Freshchat IDs present in metadata

3. **Operator trust (UAT).** Have 2–3 support agents review a subset of migrated conversations in the Unthread Slack inbox. Provide them with the same conversations in Freshchat for side-by-side comparison. If agents do not trust the migrated data, the migration has not succeeded regardless of the record counts.

4. **KB article review.** Open imported articles in Unthread and check formatting (Markdown rendering), links (no broken URLs), embedded images, and user group assignments.

5. **Rollback readiness.** If the migration fails validation, have a deletion script ready that iterates through your crosswalk table and deletes all imported records via `DELETE /conversations/:id`. Test this script in a sandbox before production migration.

For a comprehensive QA process, use our [post-migration QA checklist](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

## Post-Migration Tasks

Once the data is moved, the operational transition begins.

- **Rebuild automations.** Recreate Freshchat assignment rules and bot flows using Unthread's automation engine (event-triggered, scheduled, or webhook-based). Document the mapping: for each Freshchat rule, record what it did, then build the Unthread equivalent. Our guide on [migrating automations, macros, and workflows](https://clonepartner.com/blog/blog/how-to-migrate-automations-macros-workflows/) covers this in detail.
- **Configure SLAs and escalations.** Set up Support Steps on Customers with assignment, escalation, and reminder rules. Map Freshchat's SLA tiers (response time, resolution time, business hours) to Unthread's SLA configuration.
- **Train your team.** Unthread's workflow lives inside Slack. Agents accustomed to Freshchat's web dashboard need hands-on onboarding for Slack-native ticket management. Budget 2–3 hours of guided training per agent. Key differences to cover: how to claim tickets, how to add internal notes, how to change status, and how to use the Unthread web dashboard for reporting.
- **Set up knowledge base sync.** Connect Unthread to your documentation sources (Confluence, Notion, Google Docs, etc.) so the AI agent has up-to-date context for auto-resolution.
- **Disable Freshchat intake.** Once validation passes and the parallel run is complete, disable Freshchat's widget/channels to prevent new conversations from landing in the old system. Do not delete the Freshchat account — maintain read-only access for 30–90 days.
- **Monitor for gaps.** Watch for orphaned tickets, broken attachment links, conversations that did not import cleanly, or unmapped fields during the first two weeks. Set up a daily check for the first week: query Unthread's API for conversations with missing `customerId`, empty `assignedToUserId`, or no tags.
- **Delete staged data.** If you staged PII in a local database during migration, delete it within 30 days of migration completion per your data retention policy.

## Best Practices

- **Back up everything.** Take a full JSON export of your Freshchat instance, attachment files (downloaded locally, not just URLs), and raw API payloads before the first test run. Store this backup for at least 90 days.
- **Run a pilot migration.** Migrate a small subset (500–1,000 conversations, including some with attachments, bot interactions, and custom fields) to a sandbox Unthread workspace. This surfaces edge cases before they affect production data. Expect the pilot to reveal 3–5 mapping issues you didn't anticipate.
- **Preserve legacy IDs.** Store Freshchat `conversation_id`, `contact_id`, and `agent_id` in Unthread metadata fields. You will need them during validation, debugging, and for any future reference back to the original system.
- **Validate incrementally.** Check data after every batch of 1,000–5,000 records, not just at the end. Catching a mapping error at record 2,000 is much cheaper than discovering it at record 80,000.
- **Keep the delta window short.** Minimize the period where conversations could land in both systems. A 3–5 day parallel run is typical; longer than 2 weeks creates reconciliation headaches.
- **Archive what you cannot map honestly.** Do not force data into the wrong field just to avoid archiving it. WhatsApp-specific metadata, Messenger-specific user IDs, and Instagram DM formatting are better preserved as archived JSON than shoehorned into Unthread fields.
- **Structure your script for reruns.** A practical migration script has five modules: extract → stage → transform → load → validate. Add a persistent crosswalk store, structured JSON logging (not just print statements), and a retry queue with dead-letter handling. That architecture saves you from most rerun pain and produces an audit trail.
- **Encrypt staged data.** Any PII staged locally during migration (emails, phone numbers, message content) should be encrypted at rest. Use AES-256 for files or enable encryption on your staging database.

## When to Use a Managed Migration Service

DIY migrations work for small datasets with straightforward schemas. They break down when:

- You have 50,000+ conversations with complex threading and attachments
- Freshchat is coupled to Freshsales or another CRM, and separating the data requires a second extraction path
- Custom fields span multiple Ticket Types with conditional logic
- You need zero downtime during the cutover with a verified parallel-run period
- Regulatory requirements demand an auditable migration log with chain-of-custody documentation
- Your engineering team cannot dedicate 2–4 weeks to migration scripting and debugging

The hidden cost of DIY is not the initial script — it is the debugging. Rate limit handling, malformed records, expired attachment URLs, ID resolution errors, crosswalk table corruption, and explaining to agents why 200 conversations have wrong assignees consume 3–5x more time than the initial build.

**ClonePartner** is an engineer-led service that specializes in complex data migrations. We have completed [1,500+ migrations](https://clonepartner.com/blog/blog/how-we-run-migrations-at-clonepartner/) across helpdesk platforms, including chat-to-ticket schema transformations. We handle extraction, transformation, relationship rebuilding, validation, and zero-downtime cutover — typically completing migrations in 5–10 business days depending on data volume and complexity. If you want to skip the API debugging and get straight to using Unthread, [we can help](https://clonepartner.com/blog/blog/help-desk-data-migration-checklist/).

> Planning a Freshchat to Unthread migration? Our engineers handle the full data pipeline — extraction, transformation, loading, and validation — so your team stays focused on support, not scripting.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I export Freshchat conversations to CSV for Unthread import?

Freshchat can export contacts as CSV/XLSX, but conversation exports flatten chat transcripts into single text blocks — individual messages are lost. Unthread has no CSV import for conversations. For full message-level fidelity, extract via Freshchat's REST API and import into Unthread's POST /conversations and POST /conversations/:id/messages endpoints.

### Can I migrate Freshchat conversations into Unthread with original timestamps?

Not as native historical timestamps through Unthread's public API. The create and update schemas do not document backdated createdAt fields. Most teams preserve original Freshchat timestamps in metadata, notes, or inline transcript text instead. Use excludeAnalytics: true to prevent migrated data from distorting Unthread's reporting.

### What Freshchat data cannot be migrated to Unthread?

Freshchat channels like WhatsApp, Instagram, and Facebook Messenger have no native Unthread equivalent. Historical SLA metrics, analytics dashboards, IntelliAssign configurations, bot flow logic, and CSAT ratings must be rebuilt manually. Bot quick replies and carousels need to be flattened into plain text. CRM-adjacent data from Freshsales should stay in the CRM or be migrated separately.

### How do I handle Freshchat attachments when moving to Unthread?

Freshchat attachment URLs may expire. Download files locally during extraction and re-upload them through Unthread's multipart upload endpoints. Unthread enforces a 20MB max file size and a 10-file limit per Slack message. Split larger attachment sets across multiple messages.

### How long does a Freshchat to Unthread migration take?

For small accounts (under 5,000 conversations), expect 3–5 days of engineering effort for scripting, transformation, and validation. Larger accounts with 50,000+ conversations and complex custom fields may take 2–4 weeks for a DIY migration, or days with a managed migration service like ClonePartner.
