---
title: "LiveAgent to Unthread Migration: A Technical Guide"
slug: liveagent-to-unthread-migration-a-technical-guide
date: 2026-07-24
author: Nachi
categories: [Migration Guide, Unthread]
excerpt: "A technical guide to migrating from LiveAgent to Unthread. Covers API extraction, data model mapping, HTML-to-Markdown conversion, rate limits, edge cases, and validation."
tldr: "Migrating LiveAgent to Unthread requires API-to-API extraction — no native import path. Plan for 180 RPM rate limits, contact-to-account flattening, and HTML-to-Markdown conversion."
canonical: https://clonepartner.com/blog/liveagent-to-unthread-migration-a-technical-guide/
---

# LiveAgent to Unthread Migration: A Technical Guide


Migrating from LiveAgent to Unthread is a **schema translation** project, not a simple export/import. LiveAgent is a full-featured omnichannel helpdesk built around tickets, contacts, companies, and departments. Unthread is a Slack-native, conversation-first ticketing system. Their data models overlap only partially, and there is no native migration path between them — no built-in import wizard in Unthread and no direct export-to-Unthread option in LiveAgent.

Every migration requires extracting data from LiveAgent's REST API (v1 or v3), transforming it into Unthread's object model, and loading it through Unthread's API. If you need historical tickets, notes, attachments, and account relationships, plan on API/ETL or a managed service. For background on what to move and what to leave behind, see our [Help Desk Data Migration Playbook](https://clonepartner.com/blog/blog/help-desk-data-migration-playbook/).

## Why Teams Move from LiveAgent to Unthread

**LiveAgent** is an all-in-one customer service platform combining live chat, email ticketing, a built-in call center, social media monitoring, and knowledge base management. It supports email, live chat, phone, Facebook, Instagram, WhatsApp, Telegram, and more. Pricing starts at $15/agent/month (Small plan) and scales through Medium ($29), Large ($49), and Enterprise ($69) tiers.

**Unthread** is a Slack-native AI helpdesk that converts Slack conversations into tracked, routed, and resolved tickets. It handles support intake across Slack, email, web chat, Microsoft Teams, and API. Unthread targets B2B support teams, internal IT/HR/ops teams, and companies running customer support through Slack Connect channels.

Teams typically make this move for three reasons:

1. **Slack-first operations** — The support team already lives in Slack. LiveAgent's Slack integration sends notifications, but it doesn't make Slack the primary support interface. Unthread does.
2. **Simplified tooling** — LiveAgent's feature surface (call center, social media, contact forms, gamification) is overkill for teams that only need Slack-based ticketing with SLAs, routing, and AI deflection.
3. **AI-native automation** — Unthread offers AI-driven triage and automated knowledge base article generation from resolved tickets.

The inverse matters too: if your team relies on LiveAgent's call center, WhatsApp/Facebook integrations, or multi-brand customer portal, Unthread is not a drop-in replacement. Budget for process redesign if those channels are critical.

## The Core Data Model Clash

The fundamental challenge is architectural. LiveAgent organizes work around tickets plus contacts and optional company membership. Unthread centers on Accounts for external organizations, Customers as channel-level entities, and Conversations as the core work object.

| LiveAgent Object | Unthread Equivalent | Notes |
|---|---|---|
| Ticket | Conversation | Core mapping. Statuses differ (see mapping table below). |
| Contact | Account or Customer (partial) | LiveAgent contacts are people; Unthread Customers are Slack channels. Accounts are the closest org-level equivalent. |
| Company | Account | Unthread Accounts hold org-level data: name, domains, Slack channels, support assignees. |
| Department | Project | Unthread Projects scope conversations, users, and settings. |
| Agent | User | Unthread Users are synced from Slack workspace membership. |
| Tag | Tag | Direct mapping. |
| Custom Ticket Fields | Ticket Type Fields | LiveAgent allows unlimited custom fields globally; Unthread scopes custom fields to Ticket Types. |
| Custom Contact Fields | Account Custom Fields (partial) | Limited mapping. Unthread Accounts support `customFields` but the schema is less flexible. |
| Knowledge Base Article | Knowledge Base Article | Direct API-to-API mapping available. |
| Canned Messages / Predefined Answers | Templates & Canned Responses | Manual recreation typically needed. |
| SLA Rules | SLA configuration | Rebuilt in Unthread, not migrated as data. |
| Automation Rules | Automations | Rebuilt, not migrated. Unthread automations use a different trigger/step model. |

> [!WARNING]
> **Key risk:** LiveAgent Contacts are individual people with email addresses, phone numbers, and custom fields. Unthread has no direct "contact" object — its Customer object represents a Slack channel, and its Account object represents an organization. Individual contact records must be flattened into Accounts or handled through metadata.

Two mismatches are easy to miss. LiveAgent restricts each contact to a single company, while Unthread Accounts can group multiple associated Slack channels and email domains. And LiveAgent's panel CSV export is preview-only for tickets — it omits full message bodies, attachments, and custom fields. ([faq.liveagent.com](https://faq.liveagent.com/380237-How-to-backup-all-tickets))

## Migration Approaches

There is no documented first-party LiveAgent bridge in Unthread. The method you choose matters more here than in a like-for-like helpdesk swap.

### 1. CSV Export + API Import Hybrid

**How it works:** Export tickets from LiveAgent's UI using the "Export to CSV" button, then use the CSV as an intermediary to script Unthread API calls.

**When to use:** Small datasets (under 1,000 tickets) where message-level detail isn't required.

**Pros:** Quick to start — no LiveAgent API key needed for the export step. Useful for auditing data before a full API migration.

**Cons:** LiveAgent's CSV export captures ticket metadata (ID, subject, owner, tags, status) but not full message bodies. Attachments are not included. Unthread has no CSV import — you still need the Unthread API for the load side.

**Complexity:** Medium

### 2. API-Based Migration (LiveAgent REST API → Unthread REST API)

**How it works:** Extract data from LiveAgent using its v3 REST API (`GET /tickets`, `GET /contacts`, `GET /companies`, `GET /tags`, `GET /departments`), transform it in an intermediary layer, and load it into Unthread via `POST /conversations`, `POST /accounts`, `POST /tags`, and `POST /knowledge-base/articles`.

**When to use:** Any migration with more than a few hundred tickets, or when you need message-level fidelity.

**Pros:** Full control over data transformation. Can preserve message bodies, timestamps, tags, and custom fields. Scriptable and repeatable.

**Cons:** LiveAgent's API rate limit is 180 requests/minute per API key. The v3 `/tickets` endpoint caps at 10,000 records per filter — requires date-range windowing for larger datasets. ([support.liveagent.com](https://support.liveagent.com/170916-Limitation-of-API-to-retrieve-tickets-since-version-551)) Engineering effort: 2–4 weeks for a mid-size dataset (1K–20K tickets).

**Complexity:** High

### 3. Database Dump + Custom ETL

**How it works:** Request an encrypted database dump from LiveAgent support (`support@liveagent.com`), then build a custom ETL pipeline to transform and load into Unthread.

**When to use:** Enterprise datasets where API rate limits make extraction impractical, or when you need raw access to all data including call recordings, internal notes, and audit logs.

**Pros:** Access to raw, complete data — no API pagination limits. Can include data not exposed via API.

**Cons:** Must be requested from LiveAgent support; not self-service. Raw database schema requires significant reverse-engineering. LiveAgent notes that data older than 7 days is moved to AWS S3 storage, so dump timing matters. Still requires Unthread API for the load side.

**Complexity:** High

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

**How it works:** Use LiveAgent triggers/actions in Zapier or LiveAgent plus HTTP modules in Make, and call the Unthread API to sync records. Pipedream has an official Unthread integration with Create Customer, Delete Customer, and Update Customer actions.

**When to use:** Ongoing sync of new tickets or lightweight real-time forwarding during a phased rollout — not for historical migration.

**Pros:** No custom code for simple flows. Good for operational glue after the main migration.

**Cons:** Not designed for bulk historical migration. Rate limits and execution timeouts apply. Limited data mapping flexibility. No message-level import through standard connectors.

**Complexity:** Low (for simple flows)

### 5. Managed Migration Service

**How it works:** A migration partner handles extraction, transformation, loading, validation, and rollback planning.

**When to use:** Datasets over 5,000 tickets, complex custom field schemas, or when engineering bandwidth is limited.

**Pros:** Handles edge cases (duplicate contacts, attachment expiry, rate limit management). Includes validation and QA.

**Cons:** External cost. Requires sharing API credentials with a third party.

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

### Comparison Table

| Approach | Best For | Message Bodies | Attachments | Complexity | Typical Timeline |
|---|---|---|---|---|---|
| CSV + API Hybrid | < 1K tickets | ❌ Previews only | ❌ No | Medium | 1–2 weeks |
| API-to-API | 1K–50K+ tickets | ✅ Yes | ✅ Yes (manual download) | High | 2–4 weeks |
| DB Dump + ETL | Enterprise / 50K+ | ✅ Yes | ✅ Yes | High | 3–5 weeks |
| Middleware | Ongoing sync only | ❌ Limited | ❌ No | Low | Days |
| Managed Service | Any scale | ✅ Yes | ✅ Yes | Low (your side) | Days to 2 weeks |

### Scenario Recommendations

- **Small team, < 1K tickets, no custom objects:** CSV export + API import hybrid. Budget a week.
- **Mid-market, 1K–20K tickets, custom fields and tags:** API-to-API migration with date-range windowing. Budget 2–3 weeks of engineering time.
- **Enterprise, 20K+ tickets, attachments, KB articles:** Managed migration service or DB dump + ETL. At 180 RPM, 20K tickets with ~5 messages each takes roughly 10 hours of continuous extraction alone.
- **Ongoing sync after migration:** Middleware (Pipedream or Zapier) for new ticket forwarding.
- **Low engineering bandwidth:** Do not attempt an in-house API build. Use a managed service to prevent blocking product roadmaps.

## Pre-Migration Planning

### Data Audit Checklist

Before writing any code, inventory what you have and what you need:

- [ ] **Tickets:** Total count, date range, status distribution (open/closed/resolved)
- [ ] **Contacts:** Total count, deduplication needs, custom contact fields in use
- [ ] **Companies:** Total count, contact-to-company relationships
- [ ] **Tags:** Full list, usage frequency, which are still relevant
- [ ] **Departments:** List and mapping to Unthread Projects
- [ ] **Custom Fields:** Ticket fields and contact fields — types, picklist values, required flags
- [ ] **Knowledge Base Articles:** Count, categories, translations
- [ ] **Attachments:** Total volume (GB), average per ticket
- [ ] **Canned Messages / Predefined Answers:** Count and relevance
- [ ] **Automation Rules:** Which need to be rebuilt in Unthread

### GDPR and Data Residency Considerations

For teams subject to GDPR or other data residency regulations, plan for these requirements during migration:

- **Data in transit:** All API calls to both LiveAgent and Unthread should use TLS 1.2+. If your ETL pipeline runs on a separate server, ensure it does not persist plaintext PII to unencrypted disk.
- **Data residency:** LiveAgent hosts on AWS (US and EU regions depending on account). Unthread's infrastructure is US-based. If your LiveAgent instance is in the EU, migrating to Unthread may move personal data outside the EU. Confirm with your DPO and consider a Data Processing Agreement (DPA) with Unthread before migration.
- **Right to erasure:** If any contacts or tickets have pending GDPR deletion requests in LiveAgent, process them *before* extraction to avoid importing data you're legally obligated to delete.
- **API credentials:** Store LiveAgent and Unthread API keys in a secrets manager (AWS Secrets Manager, HashiCorp Vault, or equivalent) — not in migration scripts, environment variables on shared machines, or version control.
- **Post-migration cleanup:** After validation, delete any intermediate data stores (local JSON dumps, staging databases) that contain PII.

### What NOT to Migrate

Not everything needs to move:

- Spam or junk tickets
- Tickets older than your retention requirement
- Test or internal tickets
- Orphaned contacts with no ticket history
- Deprecated custom fields
- Call recordings (Unthread has no native voice support)
- Social channel conversations (Facebook, Instagram, Twitter) — Unthread has no matching channels

For guidance on scoping decisions, see [Help Desk Data Migration Playbook: What Data to Move and What to Leave Behind](https://clonepartner.com/blog/blog/help-desk-data-migration-playbook/).

### Migration Strategy

| Strategy | Description | When to Use |
|---|---|---|
| **Big Bang** | Migrate everything in one cutover window | Small datasets (< 2K tickets), tight timelines |
| **Phased** | Migrate by department, date range, or data type | Large datasets, multiple teams |
| **Incremental** | Migrate historical data first, then delta sync recent tickets | Minimal downtime requirements |

For most LiveAgent-to-Unthread migrations, **phased by date range** works best because LiveAgent's API requires date-range filtering for datasets over 10,000 tickets anyway.

## Migration Architecture

### Data Flow

```
LiveAgent API v3          Transform Layer          Unthread API
─────────────────    ─────────────────────    ──────────────────
GET /tickets     →   Map statuses, fields  →  POST /conversations
GET /contacts    →   Flatten to Accounts   →  POST /accounts
GET /companies   →   Map to Accounts       →  POST /accounts
GET /tags        →   Direct mapping        →  POST /tags
GET /departments →   Map to Projects       →  (Manual/UI setup)
GET /kb/articles →   Content transform     →  POST /knowledge-base/articles
```

### LiveAgent API v1 vs. v3: When to Use Which

LiveAgent maintains two API versions. Most migration work should use v3, but v1 is required for specific operations:

| Capability | v1 | v3 | Recommendation |
|---|---|---|---|
| Ticket listing with filters | ✅ | ✅ (capped at 10K per filter) | v3 for structured extraction |
| Ticket message retrieval | ✅ | ✅ | v3 — returns `download_url` for attachments |
| Contact/Company CRUD | ✅ | ✅ | v3 for consistent pagination |
| Tag management | ✅ | ✅ | Either version |
| Call recordings | ✅ | ❌ Not available | v1 only (if needed for archival) |
| Webhook management | ✅ | ❌ | v1 only |
| Authentication | Query parameter `?apikey=` | Header `apikey:` | v3 preferred (credentials not in URL logs) |
| Pagination style | Offset-based | `_page`/`_perPage` or `_cursor` | v3 cursor-based for large sets |

**General rule:** Use v3 for all ticket, contact, company, and tag extraction. Fall back to v1 only for endpoints not available in v3 (webhooks, call recordings, certain agent configuration endpoints).

### LiveAgent API Constraints

- **Rate limit:** 180 requests/minute per API key. For large migrations, you can use multiple API keys (each agent can generate one) to parallelize extraction — each key has its own 180 RPM quota. Coordinate across keys to avoid duplicate data pulls by assigning each key a distinct date range or department filter.
- **Pagination:** v3 uses `_page` and `_perPage` parameters; some endpoints use `_cursor` instead (calls, tickets/history, phone_numbers)
- **Record cap:** v3 `/tickets` returns max 10,000 records per filter set. Use date-range windowing (week-by-week or month-by-month) for larger datasets
- **Attachment URLs:** Since version 5.25, the `downloadUrl` in file responses may be incorrect. Use the `download_url` returned from `api/v3/ticket/{ticket_id}/messages` instead
- **Note type changes:** Version 5.62 changed note type values and can mix old and new note group IDs (integer and UUID) in a single ticket response. Treat note identifiers as strings. ([support.liveagent.com](https://support.liveagent.com/389303-Changes-to-ticket-note-types-in-version-562))
- **Timezone:** API v3 returns dates in the account's timezone, not UTC. Add a `Timezone-Offset` header if needed
- **Authentication:** API key passed in the `apikey` header (v3) or as a query parameter (v1)

### Unthread API Constraints

- **Rate limit:** Not publicly documented. In practice, sustained request rates above approximately 60–120 requests/minute have triggered 429 responses. Implement exponential backoff with jitter on 429 responses — start with a 1-second delay and double on each retry, up to a 60-second maximum.
- **Pagination:** List endpoints return max 100 records per page, cursor-based
- **Conversation types:** `slack` (posts to Slack channel/DM), `triage` (internal only), `email` (email-backed)
- **Attachments:** Max 10 files per Slack message, max 20MB per file. Use `multipart/form-data` requests
- **Authentication:** `X-Api-Key` header with a service account key
- **Content format:** Message content should follow Slack `mrkdwn` formatting conventions
- **Backdate limitation:** `createdAt` is set at creation time — you cannot inject a historical timestamp
- **Webhooks:** Unthread supports outgoing webhooks for conversation and message events, which can be used for post-migration validation monitoring (e.g., confirming that new conversations are routing correctly after cutover)

> [!NOTE]
> **Critical decision:** When importing historical tickets, use `type: "triage"` to create internal-only records and avoid flooding Slack channels with old conversations. For tickets that should appear in Slack channels, use `type: "slack"` with the appropriate `channelId`.

## Step-by-Step Migration Process

### Step 1: Extract Reference Data from LiveAgent

Start by extracting tags, departments, companies, and contacts. Store them locally as JSON for the transform step.

```python
import requests
import time
import json
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("liveagent_extract")

LA_BASE = "https://youraccount.ladesk.com/api/v3"
API_KEY = "your-liveagent-api-key"
HEADERS = {"apikey": API_KEY}

def extract_all(endpoint, max_retries=3):
    """Generic paginated extraction for contacts, companies, tags."""
    results = []
    page = 1
    while True:
        for attempt in range(max_retries):
            try:
                resp = requests.get(
                    f"{LA_BASE}/{endpoint}",
                    headers=HEADERS,
                    params={"_page": page, "_perPage": 100},
                    timeout=30
                )
                if resp.status_code == 429:
                    wait = min(2 ** attempt * 10, 120)
                    logger.warning(f"Rate limited on {endpoint} page {page}, waiting {wait}s")
                    time.sleep(wait)
                    continue
                resp.raise_for_status()
                data = resp.json()
                break
            except requests.exceptions.RequestException as e:
                logger.error(f"Request failed for {endpoint} page {page}: {e}")
                if attempt == max_retries - 1:
                    raise
                time.sleep(2 ** attempt)
        else:
            break

        if not data:
            break
        results.extend(data)
        logger.info(f"Extracted {len(results)} records from {endpoint}")
        page += 1
        time.sleep(0.35)  # Stay under 180 RPM
    return results

contacts = extract_all("contacts")
companies = extract_all("companies")
tags = extract_all("tags")

# Persist to disk for transform step
for name, data in [("contacts", contacts), ("companies", companies), ("tags", tags)]:
    with open(f"extract_{name}.json", "w") as f:
        json.dump(data, f)
    logger.info(f"Saved {len(data)} {name} to extract_{name}.json")
```

### Step 2: Extract Tickets and Messages with Date Windowing

Use date-range filtering to stay under the 10,000-record-per-filter cap.

```python
from datetime import datetime, timedelta

def extract_tickets_by_date_range(start_date, end_date, max_retries=3):
    """Extract tickets using date-range windowing to stay under 10K limit."""
    tickets = []
    page = 1
    while True:
        params = {
            "_page": page,
            "_perPage": 50,
            "_filters": json.dumps({
                "date_created": {"from": start_date, "to": end_date}
            })
        }
        for attempt in range(max_retries):
            try:
                resp = requests.get(
                    f"{LA_BASE}/tickets", headers=HEADERS,
                    params=params, timeout=30
                )
                if resp.status_code == 429:
                    wait = min(2 ** attempt * 10, 120)
                    logger.warning(f"Rate limited, waiting {wait}s (attempt {attempt+1})")
                    time.sleep(wait)
                    continue
                resp.raise_for_status()
                data = resp.json()
                break
            except requests.exceptions.RequestException as e:
                logger.error(f"Ticket extraction failed: {e}")
                if attempt == max_retries - 1:
                    # Log to dead-letter file for manual retry
                    with open("dead_letter.jsonl", "a") as dl:
                        dl.write(json.dumps({
                            "type": "ticket_extraction",
                            "params": params,
                            "error": str(e),
                            "timestamp": datetime.utcnow().isoformat()
                        }) + "\n")
                    data = []
                    break
                time.sleep(2 ** attempt)
        else:
            break

        if not data:
            break
        tickets.extend(data)
        page += 1
        time.sleep(0.35)
    logger.info(f"Extracted {len(tickets)} tickets for {start_date} to {end_date}")
    return tickets

def extract_ticket_messages(ticket_id, max_retries=3):
    """Extract all messages for a single ticket."""
    for attempt in range(max_retries):
        try:
            resp = requests.get(
                f"{LA_BASE}/tickets/{ticket_id}/messages",
                headers=HEADERS, timeout=30
            )
            if resp.status_code == 429:
                time.sleep(min(2 ** attempt * 10, 120))
                continue
            resp.raise_for_status()
            time.sleep(0.35)
            return resp.json()
        except requests.exceptions.RequestException as e:
            logger.error(f"Message extraction failed for ticket {ticket_id}: {e}")
            if attempt == max_retries - 1:
                return []
            time.sleep(2 ** attempt)

# Example: extract in weekly windows
def extract_all_tickets(start_date_str, end_date_str):
    """Extract all tickets using weekly date windows."""
    start = datetime.strptime(start_date_str, "%Y-%m-%d")
    end = datetime.strptime(end_date_str, "%Y-%m-%d")
    all_tickets = []
    window_start = start
    while window_start < end:
        window_end = min(window_start + timedelta(days=7), end)
        batch = extract_tickets_by_date_range(
            window_start.strftime("%Y-%m-%d"),
            window_end.strftime("%Y-%m-%d")
        )
        all_tickets.extend(batch)
        window_start = window_end
    return all_tickets
```

### Step 3: Transform Data to Unthread Schema

Map LiveAgent objects to Unthread's model. This is where most of the engineering work happens.

```python
import html2text

UT_BASE = "https://api.unthread.io/api"
UT_HEADERS = {"X-Api-Key": "your-unthread-api-key", "Content-Type": "application/json"}

# HTML to Slack mrkdwn converter
h2t = html2text.HTML2Text()
h2t.body_width = 0  # Disable line wrapping

def html_to_mrkdwn(html_content):
    """Convert HTML to Slack mrkdwn format.

    Known degradation cases:
    - HTML tables: converted to plain text rows, lose column alignment
    - Nested lists (>2 levels): flatten to 2 levels in mrkdwn
    - Colored/styled text: CSS styles stripped entirely
    - Inline images (base64 or <img> tags): converted to URL-only text, need separate attachment upload
    - <pre> code blocks with syntax highlighting: lose language-specific highlighting
    - Right-to-left text: directionality markers stripped
    """
    if not html_content:
        return ""
    markdown = h2t.handle(html_content)
    # Slack mrkdwn uses *bold* not **bold**, _italic_ not *italic*
    markdown = markdown.replace("**", "*")
    return markdown.strip()

def map_status(la_status):
    """Map LiveAgent ticket statuses to Unthread conversation statuses."""
    mapping = {
        "N": "open",        # New
        "T": "open",        # Answered (by customer)
        "A": "in_progress", # Answered (by agent)
        "P": "on_hold",     # Postponed
        "R": "closed",      # Resolved
        "X": "closed",      # Deleted
        "B": "closed",      # Spam
        "C": "open",        # Chatting
        "W": "open",        # Calling
    }
    return mapping.get(la_status, "open")

def transform_company_to_account(company):
    """Transform a LiveAgent company to an Unthread account."""
    return {
        "name": company.get("name", "Unknown"),
        "emailsAndDomains": [company["email"]] if company.get("email") else [],
    }

def transform_ticket_to_conversation(ticket, department_to_project_map):
    """Transform a LiveAgent ticket into an Unthread conversation."""
    return {
        "type": "triage",  # Use triage for historical imports
        "markdown": ticket.get("subject", "(no subject)"),
        "status": map_status(ticket.get("status", "N")),
        "title": ticket.get("subject") or "(no subject)",
        "triageChannelId": "YOUR_TRIAGE_CHANNEL_ID",
        "projectId": department_to_project_map.get(ticket.get("department_id")),
        "metadata": {
            "sourceId": str(ticket.get("id")),
            "sourceCode": ticket.get("code"),
            "sourceCreatedAt": ticket.get("date_created"),
            "sourceStatus": ticket.get("status"),
        }
    }
```

### Step 4: Load into Unthread

Create Accounts first, then Tags, then Conversations, then Messages for each conversation. This dependency order matters because conversations reference account IDs and tag IDs.

```python
# Reconciliation table: tracks source→target ID mappings and load status
import sqlite3

def init_reconciliation_db(db_path="reconciliation.db"):
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS migration_log (
            source_type TEXT,
            source_id TEXT,
            target_id TEXT,
            load_status TEXT,
            retry_count INTEGER DEFAULT 0,
            error_message TEXT,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (source_type, source_id)
        )
    """)
    conn.commit()
    return conn

def already_migrated(conn, source_type, source_id):
    row = conn.execute(
        "SELECT target_id FROM migration_log WHERE source_type=? AND source_id=? AND load_status='success'",
        (source_type, source_id)
    ).fetchone()
    return row[0] if row else None

def log_migration(conn, source_type, source_id, target_id, status, error=None):
    conn.execute("""
        INSERT OR REPLACE INTO migration_log
        (source_type, source_id, target_id, load_status, error_message)
        VALUES (?, ?, ?, ?, ?)
    """, (source_type, source_id, target_id, status, error))
    conn.commit()

def create_account(account_data, conn, source_id, max_retries=3):
    existing = already_migrated(conn, "account", source_id)
    if existing:
        logger.info(f"Account {source_id} already migrated as {existing}")
        return {"id": existing}

    for attempt in range(max_retries):
        try:
            resp = requests.post(f"{UT_BASE}/accounts", headers=UT_HEADERS, json=account_data, timeout=30)
            if resp.status_code == 429:
                time.sleep(min(2 ** attempt * 5, 60))
                continue
            resp.raise_for_status()
            result = resp.json()
            log_migration(conn, "account", source_id, result.get("id"), "success")
            return result
        except requests.exceptions.RequestException as e:
            logger.error(f"Failed to create account {source_id}: {e}")
            if attempt == max_retries - 1:
                log_migration(conn, "account", source_id, None, "failed", str(e))
                return None
            time.sleep(2 ** attempt)

def create_conversation(conv_data, conn, source_id, max_retries=3):
    existing = already_migrated(conn, "conversation", source_id)
    if existing:
        logger.info(f"Ticket {source_id} already migrated as {existing}")
        return {"id": existing}

    for attempt in range(max_retries):
        try:
            resp = requests.post(f"{UT_BASE}/conversations", headers=UT_HEADERS, json=conv_data, timeout=30)
            if resp.status_code == 429:
                time.sleep(min(2 ** attempt * 5, 60))
                continue
            resp.raise_for_status()
            result = resp.json()
            log_migration(conn, "conversation", source_id, result.get("id"), "success")
            return result
        except requests.exceptions.RequestException as e:
            logger.error(f"Failed to create conversation for ticket {source_id}: {e}")
            if attempt == max_retries - 1:
                log_migration(conn, "conversation", source_id, None, "failed", str(e))
                return None
            time.sleep(2 ** attempt)

def add_message(conversation_id, message_text, on_behalf_of=None):
    payload = {"body": {"type": "markdown", "value": message_text}}
    if on_behalf_of:
        payload["onBehalfOf"] = on_behalf_of
    resp = requests.post(
        f"{UT_BASE}/conversations/{conversation_id}/messages",
        headers=UT_HEADERS,
        json=payload,
        timeout=30
    )
    return resp.json()
```

### Step 5: Handle Attachments

LiveAgent attachment URLs require authentication and cannot be passed directly to Unthread. Download each file during ETL and re-upload.

```python
import os

def download_attachment(download_url, filename, dest_dir="attachments"):
    """Download attachment from LiveAgent (requires API key auth)."""
    os.makedirs(dest_dir, exist_ok=True)
    resp = requests.get(download_url, headers=HEADERS, stream=True, timeout=60)
    resp.raise_for_status()
    filepath = os.path.join(dest_dir, filename)
    with open(filepath, "wb") as f:
        for chunk in resp.iter_content(chunk_size=8192):
            f.write(chunk)
    return filepath

def upload_attachment_to_conversation(conversation_id, filepath):
    """Upload file to Unthread conversation. Max 20MB per file, 10 files per message."""
    filesize = os.path.getsize(filepath)
    if filesize > 20 * 1024 * 1024:
        logger.warning(f"File {filepath} exceeds 20MB limit ({filesize} bytes), skipping")
        return None

    with open(filepath, "rb") as f:
        resp = requests.post(
            f"{UT_BASE}/conversations/{conversation_id}/messages",
            headers={"X-Api-Key": UT_HEADERS["X-Api-Key"]},
            files={"file": (os.path.basename(filepath), f)},
            timeout=120
        )
    return resp.json()
```

### Step 6: Apply Tags and Store Source IDs

After loading conversations, link them to accounts and apply tags.

```python
def apply_tags(conversation_id, tag_ids):
    for tag_id in tag_ids:
        requests.post(
            f"{UT_BASE}/tags/{tag_id}/conversations/create-links",
            headers=UT_HEADERS,
            json=[conversation_id],
            timeout=30
        )
        time.sleep(0.35)
```

For larger projects, structure the migration code as separate modules rather than one monolithic script:

```text
migrate/
  extract_liveagent.py
  map_models.py
  load_unthread.py
  attachments.py
  reconcile.py
  validate.py
  config.yml
  dead_letter.jsonl
  reconciliation.db
```

This keeps extraction, mapping, loading, and reconciliation testable and independently rerunnable.

## Unthread API Response Shapes

When building your pipeline, expect these response structures from Unthread:

**Successful conversation creation** (`POST /conversations`, 201):
```json
{
  "id": "conv_abc123",
  "title": "Order issue #4521",
  "status": "open",
  "type": "triage",
  "createdAt": "2024-01-15T10:30:00.000Z",
  "projectId": "proj_xyz789"
}
```

**Successful account creation** (`POST /accounts`, 201):
```json
{
  "id": "acct_def456",
  "name": "Acme Corp",
  "emailsAndDomains": ["acme.com"]
}
```

**Rate limit error** (429):
```json
{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Please retry after a short delay."
}
```

**Validation error** (400):
```json
{
  "error": "Bad Request",
  "message": "triageChannelId is required when type is 'triage'"
}
```

Use these shapes to build typed response parsers rather than relying on unstructured `resp.json()` access.

## Field-Level Data Mapping

| LiveAgent Field | Unthread Field | Transformation |
|---|---|---|
| `ticket.id` | `conversation.metadata.sourceId` | Store as metadata for traceability |
| `ticket.code` | `conversation.metadata.sourceCode` | Store as metadata |
| `ticket.subject` | `conversation.title` | Direct; default to `"(no subject)"` if null |
| `ticket.status` | `conversation.status` | Map: N/T/C/W→open, A→in_progress, P→on_hold, R/X/B→closed |
| `ticket.date_created` | `conversation.metadata.sourceCreatedAt` | Cannot backdate; store in metadata |
| `ticket.department_id` | `conversation.projectId` | Map departments to Projects |
| `ticket.owner_contactid` | `conversation.customerId` | Map via Account/Customer lookup |
| `ticket.agent_id` | `conversation.assignedToUserId` | Map LiveAgent agents to Unthread Users (matched by email) |
| `ticket.tags` | `conversation.tags` | Map tag names → Unthread tag IDs |
| `ticket.custom_fields` | `conversation.ticketTypeFields` | Map per Ticket Type schema |
| `contact.email` | `account.emailsAndDomains` | Direct |
| `contact.name` | `account.name` | Direct (or concatenate first+last) |
| `company.name` | `account.name` | Direct |
| `message.body` | `message.body.value` | HTML → Slack mrkdwn conversion (see degradation notes in Step 3) |
| `kb_article.title` | `article.title` | Direct |
| `kb_article.content` | `article.content` | HTML → Markdown |

> [!TIP]
> **Store source IDs in metadata.** Map `ticket.id` and `ticket.code` from LiveAgent into the `metadata` field on Unthread conversations. This creates a traceable link back to the source record for validation and debugging.

## Edge Cases and Challenges

### Duplicate Contacts

LiveAgent allows multiple contacts with the same email. Unthread Accounts use `emailsAndDomains` as a matching key. Deduplicate contacts before import — merge by email address, keeping the most recent or most active record. A practical deduplication approach: group contacts by normalized email (lowercase, trimmed), select the record with the most associated tickets, and merge custom field values from secondary records into the primary.

### Contact-to-Account Flattening

LiveAgent's Contact→Company hierarchy doesn't map directly to Unthread. Two approaches:

- **Company-first:** Create Unthread Accounts from LiveAgent Companies. Flatten contacts as metadata within the account.
- **Contact-first:** Create one Unthread Account per unique contact email. This inflates account counts but preserves individual history.

Most teams choose the company-first approach for cleaner analytics.

### Missing or Inconsistent Data

LiveAgent tickets can have null subjects, missing contact associations, or empty message bodies. Build null-checks into your transform layer. Default to `"(no subject)"` for titles and skip empty messages. Log every skipped record with the reason for post-migration audit.

### Attachment Migration

LiveAgent ticket attachments require downloading via the `download_url` from the messages endpoint — do not use the generic file endpoint, as the `downloadUrl` field has been unreliable since version 5.25. Re-upload to Unthread as `multipart/form-data`. Unthread enforces a **10-file-per-message limit** and **20MB max per file**. Tickets with more than 10 attachments need to be split across multiple message API calls.

> [!WARNING]
> **Attachment edge case:** LiveAgent attachment URLs require authentication. You cannot pass the LiveAgent URL to Unthread directly. You must download the binary file during the ETL process and re-upload it to Unthread's storage.

### Inline Images

LiveAgent users frequently paste images directly into HTML message bodies. These base64-encoded images or secured URLs will break in Unthread unless parsed, extracted, and re-uploaded as standard attachments. Detection approach: scan message HTML for `<img>` tags with `src="data:image/..."` (base64) or `src="https://..."` (hosted inline images), extract the binary data, save to a temp file, and upload via the attachment endpoint. Replace the `<img>` tag with a text reference like `[see attached: image_001.png]` in the converted mrkdwn.

### Note Type Changes (v5.62)

LiveAgent version 5.62 changed note type values and can mix old and new note group IDs (integer and UUID) in a single ticket response. Naive parsers that assume stable note types or integer IDs can silently miss data. Treat note identifiers as strings throughout your pipeline. ([support.liveagent.com](https://support.liveagent.com/389303-Changes-to-ticket-note-types-in-version-562))

### Missing Authors

If an agent has left the company and their LiveAgent profile was deleted, their messages may orphan. Map deleted agents to a generic "Legacy Agent" profile in Unthread to preserve the timeline. Create this user in Unthread before the migration starts and use its ID as a fallback for any unresolvable `agent_id`.

### Multi-Channel Data

LiveAgent handles Twitter, Facebook, and voice calls. Unthread has no equivalent channels. These interactions must be converted into standard text-based notes (using `type: "triage"`) or excluded from the migration scope entirely. If converting, prefix the message body with the source channel (e.g., `[Originally from Facebook Messenger]`) for context.

### Cannot Backdate Conversations

Unthread's `createdAt` timestamp is set when the conversation is created via API — you cannot inject a historical date. Store the original LiveAgent creation date in the conversation's `metadata` field for reference. This is a common limitation across Slack-native platforms. For reporting purposes, build any historical analytics from the `metadata.sourceCreatedAt` field rather than `createdAt`.

### Email Split-Brain

After go-live, incorrect BCC changes or bad forwarding rules can split one email thread into multiple Unthread conversations. Test email routing thoroughly before cutover. Send test emails from multiple reply-chain depths (initial, first reply, third reply) and verify they consolidate into a single conversation.

### API Failures and Retries

Both APIs can return 429 (rate limit) or 5xx (server errors). Implement exponential backoff with jitter. Log every failed request with the full payload to a dead-letter file for replay. Make loaders idempotent — check your reconciliation table before every create call to avoid duplicates. For Unthread 429 responses, start with a 1-second delay and double on each retry up to 60 seconds.

## Limitations and Constraints

| Constraint | Detail |
|---|---|
| **No native import path** | No built-in migration wizard or CSV import in Unthread |
| **No custom objects in Unthread** | LiveAgent's flexible custom fields must map to Ticket Type Fields, scoped per Ticket Type |
| **No voice/call data** | LiveAgent call recordings and IVR trees have no Unthread equivalent |
| **No social channel data** | Facebook, Instagram, Twitter, WhatsApp conversations have no matching channel in Unthread |
| **Contacts are not first-class** | Unthread has no standalone contact object — individual records flatten into Accounts |
| **LiveAgent rate limit** | 180 RPM per API key. Parallelizable with multiple keys. 50K tickets × 5 messages = ~250K API calls = ~23 hours per key |
| **LiveAgent filter cap** | v3 API returns max 10K tickets per filter. Must use date-range windowing |
| **Unthread backdate limitation** | Cannot set historical `createdAt` on conversations |
| **LiveAgent CSV is preview-only** | Panel CSV omits full message bodies, attachments, and custom fields |
| **Slack backfill limit** | Unthread's Slack backfill is limited to six months and imports past threads as closed tickets |
| **Data residency** | Unthread infrastructure is US-based; verify GDPR/data residency compliance before migrating EU data |

## Validation and Testing

### Record Count Comparison

After migration, compare counts across every object type:

```python
def validate_counts(conn):
    """Compare source and target record counts from reconciliation DB."""
    for source_type in ["account", "conversation", "tag"]:
        total = conn.execute(
            "SELECT COUNT(*) FROM migration_log WHERE source_type=?",
            (source_type,)
        ).fetchone()[0]
        success = conn.execute(
            "SELECT COUNT(*) FROM migration_log WHERE source_type=? AND load_status='success'",
            (source_type,)
        ).fetchone()[0]
        failed = conn.execute(
            "SELECT COUNT(*) FROM migration_log WHERE source_type=? AND load_status='failed'",
            (source_type,)
        ).fetchone()[0]
        logger.info(f"{source_type}: {success}/{total} succeeded, {failed} failed")

    # Cross-check with Unthread API
    ut_conversations = requests.post(
        f"{UT_BASE}/conversations/list",
        headers=UT_HEADERS,
        json={"limit": 1}
    ).json()
    logger.info(f"Unthread total conversations: {ut_conversations.get('totalCount', 'unknown')}")
```

### Field-Level Validation

Sample 5–10% of records and verify:

- Ticket subjects match conversation titles
- Status mapping is correct
- Tags are applied correctly
- Message count per ticket matches message count per conversation
- Attachment files are accessible and match source file sizes
- Source IDs are present and correct in metadata

### UAT Process

1. Migrate a small batch (50–100 tickets) to a test Unthread Project
2. Have support agents verify ticket content and routing
3. Check that SLA clocks, assignments, and tags behave as expected
4. Run a full migration to a staging environment
5. Final validation before production cutover

### Rollback Planning

Unthread conversations can be deleted via `DELETE /conversations/:conversationId`. Build a rollback script that reads all created IDs from your reconciliation database and reverses the import. At Unthread's observed rate limits, deleting 10,000 conversations takes approximately 1.5–3 hours. Keep LiveAgent active as a read-only archive for at least 60 days post-migration.

For a detailed post-migration QA process, see [Post-Migration QA: 20 Tests to Run After Your Help Desk Data Migration](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

## Post-Migration Tasks

- **Rebuild automations:** LiveAgent rules (time rules, SLA rules, ticket routing) must be recreated as Unthread Automations using the trigger/step model. Treat this as a [separate workstream](https://clonepartner.com/blog/blog/how-to-migrate-automations-macros-workflows/).
- **Recreate canned responses:** LiveAgent's canned messages and predefined answers become Unthread Templates & Canned Responses.
- **Configure SLAs:** Set up SLA policies in Unthread's workflow management for each Customer/Account.
- **Knowledge Base sync:** Connect documentation sources in Unthread's Knowledge Base settings for AI-powered responses.
- **Reconfigure email:** Set up email forwarding, domain verification, portal authentication, and project permissions.
- **Train agents:** Unthread operates from Slack — agents need to learn the thread-based workflow, `/unthread` commands, `/unthread send` for email replies, and the web inbox. Moving from a traditional portal to Slack requires behavioral changes. Budget 2–3 training sessions.
- **Monitor for gaps:** Run daily count comparisons for the first two weeks. Watch for conversations missing tags, unlinked accounts, duplicate email threads, or failed attachment uploads. Use Unthread webhooks to monitor conversation creation events and confirm routing rules are firing correctly.

## Best Practices

1. **Back up before extraction.** Export a full CSV snapshot from LiveAgent's ticket view as a baseline. Request a database dump if your dataset exceeds 10K tickets.
2. **Run a sandbox migration.** Migrate 100 tickets to a test Unthread Project. Validate field mapping, message fidelity, and attachment integrity before committing to a full run.
3. **Use date-range windowing.** LiveAgent's v3 API caps at 10K records per filter. Extract week-by-week or month-by-month.
4. **Parallelize with multiple API keys.** For datasets over 20K tickets, assign each LiveAgent API key a distinct date range to multiply effective throughput beyond 180 RPM.
5. **Store source IDs.** Map every LiveAgent ticket ID and code into Unthread's `metadata` field. This is your audit trail.
6. **Validate incrementally.** Don't wait until the full migration finishes. Check counts and sample records after each batch.
7. **Automate retries with dead-letter logging.** Implement retry logic with exponential backoff. Failed records go to a dead-letter file with full payloads for replay.
8. **Freeze LiveAgent schema.** Enforce a change freeze on LiveAgent custom fields and departments two weeks before the migration.
9. **Keep transform rules in code.** Not in ad-hoc spreadsheet formulas. Version-control your mapping logic.
10. **Plan for what doesn't migrate.** Call recordings, social media conversations, and complex automation rules need separate handling or manual recreation.
11. **Address GDPR before extraction.** Process any pending deletion requests in LiveAgent before pulling data into your ETL pipeline.

## Cost and Timeline Estimation

### Build vs. Buy Decision Framework

| Factor | In-House Build | Managed Migration |
|---|---|---|
| **Engineering cost** | 80–160 hours at your loaded engineering rate | Fixed project fee (typically $3K–$15K depending on volume and complexity) |
| **Calendar time** | 2–5 weeks including dry runs and validation | 3–10 business days |
| **Risk** | Higher — attachment bugs, rate limit tuning, edge cases discovered in production | Lower — pre-built handling for known issues |
| **Ongoing maintenance** | You own the script; changes to either API require updates | Not applicable (one-time engagement) |
| **Best when** | < 5K tickets, no attachments, engineering team has bandwidth | > 5K tickets, attachments, custom fields, or limited engineering capacity |

### Extraction Time Calculator

| Ticket Count | Messages per Ticket | Total API Calls | Time at 180 RPM (1 key) | Time at 180 RPM (3 keys) |
|---|---|---|---|---|
| 1,000 | 5 | ~6,000 | ~33 minutes | ~11 minutes |
| 5,000 | 5 | ~30,000 | ~2.8 hours | ~55 minutes |
| 20,000 | 5 | ~120,000 | ~11 hours | ~3.7 hours |
| 50,000 | 5 | ~300,000 | ~28 hours | ~9.3 hours |
| 100,000 | 5 | ~600,000 | ~56 hours | ~18.5 hours |

These estimates include ticket listing + message retrieval calls. Add ~10% overhead for contacts, companies, tags, and attachment downloads.

## When to Use a Managed Migration Service

Building a LiveAgent-to-Unthread migration pipeline in-house is feasible for small datasets, but the engineering cost compounds quickly:

- **Hidden complexity:** Attachment URL handling (LiveAgent's `downloadUrl` bug since v5.25), HTML-to-Markdown conversion edge cases, v5.62 note type changes, contact deduplication, and rate limit management each add 1–2 days of engineering time.
- **Opportunity cost:** Engineering hours spent on migration scripts are hours not spent on product work. At a loaded rate of $150/hour, a 120-hour migration build costs $18K in engineering time alone.
- **Risk surface:** A botched migration can lose message history, break customer associations, or create duplicate records that pollute analytics.
- **The real cost isn't the first script.** It's the second and third dry run, attachment recovery, source-to-target reconciliation, UAT support, rollback planning, and the first week of post-cutover debugging.

### What a Managed Migration Delivers

- **Dependency-aware loading:** Accounts and tags created before conversations, with proper ID mapping
- **Attachment handling:** Download from LiveAgent, re-upload to Unthread with proper file metadata
- **Custom field mapping:** LiveAgent's global custom fields mapped to Unthread's Ticket Type Fields with type validation
- **Incremental delta sync:** Historical data migrated first, then recent tickets synced before cutover
- **Validation report:** Record counts, field-level sampling, and discrepancy analysis delivered before go-live

If your migration involves more than 5,000 tickets, attachments, custom fields, or multiple departments, a managed approach typically saves 2–4 weeks of engineering time compared to building from scratch.

> Planning a LiveAgent to Unthread migration? Book a 30-minute technical scoping call to discuss your dataset size, custom field requirements, and timeline.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I import data directly into Unthread from LiveAgent?

No. There is no native migration path, CSV import, or built-in wizard in Unthread for importing LiveAgent data. You must extract via LiveAgent's REST API (v1 or v3) and load via Unthread's REST API, or use a managed migration service.

### What is LiveAgent's API rate limit for data extraction?

LiveAgent's API rate limit is 180 requests per minute per API key. The v3 /tickets endpoint also caps at 10,000 records per filter set, so you need date-range windowing for larger datasets.

### How do LiveAgent contacts map to Unthread?

Unthread has no standalone contact object. LiveAgent contacts are individual people with emails and phone numbers. In Unthread, you map companies to Accounts (which hold emailsAndDomains and Slack channels) and flatten individual contact records into Account metadata.

### Can I backdate conversations in Unthread during migration?

No. Unthread's createdAt timestamp is set when the conversation is created via API. You cannot inject a historical date. Store the original LiveAgent creation date in the conversation's metadata field for reference.

### What data does not map cleanly from LiveAgent to Unthread?

Custom fields require remodeling into Ticket Type Fields. Voice/call data, social channel history (Facebook, WhatsApp, Twitter), and internal notes all need special handling. Unthread also caps attachments at 10 files per message and 20MB per file, so large attachment sets must be split across multiple API calls.
