Skip to content

LiveAgent to Unthread Migration: A Technical Guide

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.

Nachi Nachi · · 26 min read
LiveAgent to Unthread Migration: A Technical Guide
TALK TO AN ENGINEER

Planning a migration?

Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.

Schedule a free call
  • 1,500+ migrations completed
  • Zero downtime guaranteed
  • Transparent, fixed pricing
  • Project success responsibility
  • Post-migration support included

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.

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)

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) 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.

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)
  • 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)
Info

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.

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.

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.

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.

# 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.

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.

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:

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):

{
  "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):

{
  "id": "acct_def456",
  "name": "Acme Corp",
  "emailsAndDomains": ["acme.com"]
}

Rate limit error (429):

{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Please retry after a short delay."
}

Validation error (400):

{
  "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)

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:

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.

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.
  • 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.

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.

More from our Blog

Help Desk Data Migration Playbook: What Data to Move and What to Leave Behind
Help Desk

Help Desk Data Migration Playbook: What Data to Move and What to Leave Behind

This definitive playbook answers the single most critical question: "What data do we actually need to move?". This strategic guide helps you declutter and decide what's precious and what's junk. We provide a clear breakdown of the non-negotiable Tier 1 data, like tickets , knowledge bases , and user profiles, versus the Tier 2 data that provides rich context, like automations and organizations. Use this as your strategic checklist to avoid common mistakes and ensure a flawless, functional new help desk.

Raaj Raaj · · 7 min read