---
title: "LiveAgent to SolarWinds Service Desk Migration: Technical Guide"
slug: liveagent-to-solarwinds-service-desk-migration-technical-guide
date: 2026-07-28
author: Abdul
categories: [Migration Guide, SolarWinds Service Desk]
excerpt: "Technical guide to migrating from LiveAgent to SolarWinds Service Desk. Covers API extraction, ITIL object mapping, rate limits, and step-by-step loading."
tldr: "Extract LiveAgent data via API v3 (180 req/min limit), transform to SWSD's ITIL schema, load users first via the Samanage API, then incidents with comments — strict order prevents broken relationships."
canonical: https://clonepartner.com/blog/liveagent-to-solarwinds-service-desk-migration-technical-guide/
---

# LiveAgent to SolarWinds Service Desk Migration: Technical Guide


# LiveAgent to SolarWinds Service Desk Migration: Technical Guide

> [!WARNING]
> **Before you commit:** LiveAgent is a multi-channel customer support platform with ticketing, live chat, and knowledge base. SolarWinds Service Desk (SWSD) is an ITIL-based ITSM platform with incidents, problems, changes, CMDB, and asset management. This is not a like-for-like swap — you are mapping a customer-facing support model into an IT service management framework. Every object, field, and relationship requires deliberate mapping decisions.
>
> **API versions referenced:** LiveAgent API v3, SolarWinds Service Desk (Samanage) REST API v2.1. Regional API endpoints: US `api.samanage.com`, EU `apieu.samanage.com`, APAC `apiau.samanage.com`.

Migrating from LiveAgent to SolarWinds Service Desk moves your help desk data — tickets, contacts, companies, knowledge base articles, tags, chat history, and attachments — from a multi-channel customer support platform into an ITIL-aligned service management system built around incidents, problems, changes, and a CMDB. The core challenge is structural: LiveAgent organizes data around **tickets and contacts**; SWSD organizes data around **incidents, users, and configuration items**. Every migration decision flows from that architectural gap.

The short version: extract tickets and contacts from LiveAgent via the v3 REST API, transform them into SWSD's incident and user schema, load users first, then incidents with comments and attachments, and rebuild your knowledge base as Solutions in SWSD. Automations, SLA rules, chat widgets, and IVR configurations cannot be migrated and must be rebuilt manually. The data must be migrated in a strict hierarchy — Users → Departments → Incidents → Comments → Attachments — because SWSD enforces relational dependencies (e.g., an incident must have a valid requester).

## Why Teams Migrate from LiveAgent to SolarWinds Service Desk

Organizations typically make this transition when their internal IT or HR operations outgrow a standard shared inbox or customer service tool. Common drivers:

- **ITIL alignment.** Teams that started with LiveAgent for external support now need incident-problem-change workflows, CMDB, and asset management. SWSD provides the full ITIL lifecycle — incident → problem → change → release — with approval workflows and risk assessment.
- **Asset management and CMDB.** LiveAgent has no asset tracking or CMDB. SWSD integrates asset discovery with service management, letting you link hardware and software to incidents.
- **Internal service catalogs.** Moving from public-facing knowledge bases to structured internal employee service portals and formalized service request workflows.
- **Enterprise compliance.** Organizations requiring formal change advisory boards, audit trails on configuration items, and SLA enforcement across multiple service providers often outgrow LiveAgent's scope.
- **Consolidation.** Companies running LiveAgent alongside a separate ITSM tool consolidate into SWSD to reduce tool sprawl.

If your current LiveAgent account is acting like a shared inbox with ticket history, this move can make sense. If you mainly want a lighter support stack, it usually does not.

## Core Data Model Differences

Understanding the structural gap between these two platforms is the single most important pre-migration step.

**LiveAgent** is a flat, ticket-centric system. <cite index="21-7,21-8">The Companies feature manages multiple customers from the same organization, providing an additional layer of categorization.</cite> <cite index="27-2,27-3">Companies are collections of Contacts, and each contact can be added to only one company.</cite> Tickets hold messages from email, chat, social, and phone channels in a single thread.

**SolarWinds Service Desk** is an ITIL-centric, relational system. <cite index="58-1">Its core modules include Incidents, Problems, Changes, Releases, Solutions, Service Catalog, CMDB, Assets (Computers, Software, Printers, Mobile devices, Network devices, Other Assets), Tasks, Procurement, and Risk management.</cite> <cite index="57-4,57-5,57-6">The CMDB stores up-to-date information about all technological components used by an organization and the relationships between those components, with each component defined as a Configuration Item (CI) often associated with other CIs.</cite>

| LiveAgent Object | SolarWinds Service Desk Equivalent | Notes |
|---|---|---|
| Tickets | Incidents / Service Requests | Tickets map to incidents or service requests depending on type |
| Contacts | Users (Requesters) | SWSD users serve as both agents and requesters |
| Companies | Departments, Sites, or custom field | No direct "Company" object — see decision tree below |
| Agents | Users (Agent/Admin roles) | Role-based, not a separate object |
| Departments | Groups | LiveAgent departments map to SWSD groups for routing |
| Tags | Categories (partial) | Tags are free-form; SWSD categories are hierarchical |
| Knowledge Base Articles | Solutions | Different structure — Solutions use categories, not portals |
| Chat Transcripts | Incident Comments | Flattened into comment threads, losing real-time metadata |
| Custom Contact Fields | Custom User Fields | Must be manually created in SWSD first |
| Custom Ticket Fields | Custom Incident Fields | Type compatibility must be verified |
| Call Recordings | Attachments on Incidents | Audio files migrate as attachments, losing call metadata |
| Internal Notes | Private Comments | Preserved as private (agent-only) comments |
| Forums / Feedback | No equivalent | SWSD has no community forum feature |

## Migration Approaches

### 1. Native CSV Export/Import

**How it works:** <cite index="45-6,45-7">LiveAgent allows you to export tickets in HTML, PDF, or CSV format.</cite> <cite index="49-18">You can choose the ticket data columns you want to include in your export file.</cite> On the SWSD side, you can import users, incidents, and solutions via CSV templates, then use the API for anything the CSV path cannot handle.

**When to use it:** Small datasets under 5,000 tickets where you only need ticket metadata — no attachments, no chat transcripts, no multi-message threads.

**Pros:** Low engineering effort. Fast to pilot.

**Cons:** CSV export from LiveAgent captures ticket metadata but not individual messages, attachments, or chat histories. SWSD's CSV incident import only brings in text — attachments and screenshots are not imported directly. Sites and departments cannot be imported via CSV and must already exist. Updating custom fields via CSV is not supported. Excel breaks on long threads because cells over 32,767 characters can misalign rows. SWSD's CSV import supports only `New` and `Closed` as out-of-box states — custom states like `Awaiting Input` or `On Hold` require prior configuration and testing in a sandbox before relying on CSV for detailed status mapping.

**Scalability:** Small datasets only.

### 2. API-Based Migration (LiveAgent v3 → SWSD Samanage API)

**How it works:** Extract data programmatically from LiveAgent's REST API v3, transform it to match SWSD's schema, then load via the Samanage REST API. <cite index="3-4">The LiveAgent API is accessible at `https://YOURDOMAIN.ladesk.com/docs/api/v3/#/` with scopes for agents, tickets, and other objects.</cite>

Extraction endpoints:
- `GET /api/v3/tickets` — paginated ticket list
- `GET /api/v3/tickets/{ticketId}/messages` — individual messages
- `GET /api/v3/customers` — contact records
- `GET /api/v3/companies` — company records
- `GET /api/v3/agents` — agent list
- `GET /api/v3/tags` — tag list

Loading endpoints (SWSD):
- `POST https://api.samanage.com/users.json` — create users
- `POST https://api.samanage.com/incidents.json` — create incidents
- `POST https://api.samanage.com/incidents/{id}/comments.json` — add comments
- `POST https://api.samanage.com/solutions.json` — create KB articles

**When to use it:** Mid-size to large migrations (10,000–500,000+ records) where you need full fidelity — messages, attachments, relationships.

**Pros:** Full control over data transformation. Preserves message threads, attachments, and relationships. Supports delta sync and idempotent re-runs.

**Cons:** Requires engineering effort. Rate limits constrain throughput on both sides. Error handling and retry logic must be built. LiveAgent limits API requests to 180 per minute per API key and caps `/tickets` list calls at 10,000 records per filter, so large extracts must be windowed by date range.

**Scalability:** Enterprise-grade with proper batching and rate-limit management.

### 3. Third-Party Migration Tools

Tools like Help Desk Migration offer automated wizards for LiveAgent data export. <cite index="47-1,47-2">These tools can run a LiveAgent CSV export or shift data to other platforms in a few clicks.</cite> However, SolarWinds Service Desk is not commonly supported as a target in most third-party migration tools because it is an ITSM platform, not a typical help desk. LiveAgent officially highlights Help Desk Migration/Relokia for migrations *into* LiveAgent, while SolarWinds documents a free migration tool specifically for Web Help Desk to SWSD — not for LiveAgent to SWSD.

**When to use it:** Only if the specific tool explicitly supports both LiveAgent as source and SWSD as target. Verify before purchasing.

**Pros:** Reduced engineering effort if supported.

**Cons:** Limited mapping flexibility. May not support SWSD-specific objects (problems, changes, CMDB). Ticket-centric tools often struggle when the target is an ITSM schema with sites, departments, solutions, assets, and custom states.

**Scalability:** Small to medium.

### 4. Custom ETL Pipeline

**How it works:** Build an extract-transform-load pipeline using Python, Node.js, or an ETL framework (Airflow, Prefect). Extract via LiveAgent API, stage in a local database (PostgreSQL, SQLite), apply transformations, then load into SWSD via the Samanage API.

**When to use it:** Large or complex migrations with custom business logic — e.g., splitting tickets by department, mapping tags to SWSD categories, merging duplicate contacts, multi-brand consolidations, or migrations where you need a permanent archive alongside a live target.

**Pros:** Maximum flexibility. Staging database enables validation before loading. Replayable. Best auditability.

**Cons:** Highest engineering investment. Requires maintaining the pipeline until cutover.

**Scalability:** Enterprise.

### 5. Middleware Platforms (Zapier, Make)

**When to use it:** Low-volume post-cutover sync between LiveAgent and SWSD during a transition period. Not for historical bulk migration. SolarWinds documents Zapier as an app-automation path, and LiveAgent's official Zapier support is limited to a small set of customer and conversation actions.

**Pros:** No-code setup. Good for triggering incident creation from new LiveAgent tickets.

**Cons:** Not designed for bulk historical migration. Zapier has execution limits and per-task pricing that makes large migrations cost-prohibitive. LiveAgent's documented Zapier trigger/action set is far too limited for bulk historical extraction. Weak control over ordering, attachment handling, and retries.

**Scalability:** Tiny for migration, acceptable for narrow ongoing sync.

### Approach Comparison

| Approach | Best For | Historical Fidelity | Ongoing Sync | Scalability | Complexity |
|---|---|---|---|---|---|
| CSV Export/Import | < 5K tickets, metadata only | Low | No | Small | Low |
| API-Based | 10K–500K+ records, full data | High | Yes | Enterprise | High |
| Third-Party Tools | Standardized ticket/contact moves | Medium | Sometimes | Small–Medium | Low–Medium |
| Custom ETL | Enterprise, compliance, archive needs | Highest | Yes | Enterprise | High |
| Middleware (Zapier/Make) | Post-cutover automation only | Low | Yes | Small flows | Low |

### Recommendations by Scenario

- **Small business (< 5K tickets, no dev team):** CSV export for metadata plus SWSD API for loading, or a managed migration service.
- **Enterprise (50K+ records, complex data):** API-based or custom ETL pipeline. Budget 4–8 weeks.
- **One-time migration:** API-based with staged loading and validation.
- **Ongoing sync during transition:** Middleware for new records plus batch API migration for historical data.
- **Multi-brand LiveAgent accounts:** If you run multiple brands with separate inboxes, decide upfront whether to consolidate into one SWSD instance (using groups and categories for separation) or maintain logical boundaries via sites. Map each brand's department to a distinct SWSD group, and tag migrated incidents with the source brand in a custom field to preserve reporting segmentation.

## When to Use a Managed Migration Service

DIY migrations between platforms with fundamentally different data models carry real risks:

- **Broken relationships.** Loading incidents before creating their associated users results in orphaned records. Loading order matters, and it is easy to get wrong.
- **Data loss from schema mismatches.** LiveAgent's multi-channel ticket threads (email + chat + call in one ticket) must be flattened into SWSD incident comments. Losing message ordering or attribution breaks audit trails.
- **Rate-limit cascades.** <cite index="29-2,29-3">LiveAgent's API rate limit is 180 requests per minute per API key.</cite> SWSD enforces its own per-plan rate limits. A naive script hitting both limits turns a 2-day migration into 2 weeks.
- **Hidden engineering cost.** What looks like a weekend project becomes a 6-week effort once you account for pagination handling, attachment download/upload, error retries, deduplication, and validation. If the script fails halfway through, rolling back a partial SWSD import is a manual, error-prone process.

If your team's engineering bandwidth is better spent on core product work, a managed migration service handles the full pipeline — extraction, transformation, loading, relationship rebuilding, and validation. ClonePartner runs help-desk-to-ITSM migrations as a managed service. If that fits your situation, [see how we run migrations](https://clonepartner.com/blog/blog/how-we-run-migrations-at-clonepartner/).

## Pre-Migration Planning

Do not pull data before defining what actually needs to move. A messy LiveAgent instance will result in a messy SWSD instance.

### Data Audit Checklist

Before writing any migration code, inventory your LiveAgent data:

| Object | What to Count | What to Check |
|---|---|---|
| Tickets | Total count, by status, by department | Active vs. resolved, spam volume |
| Contacts | Total, duplicates, incomplete records | Missing emails, orphaned contacts |
| Companies | Total, contacts per company | Companies with no linked contacts |
| Agents | Active vs. deactivated | Role assignments |
| Tags | Total, usage frequency | Unused tags (candidates for cleanup) |
| Knowledge Base | Article count, categories | Internal vs. public, outdated articles |
| Chat Transcripts | Volume, date range | Whether chat-only tickets exist (no associated email) |
| Attachments | Total size, count | Files > 25 MB that will hit SWSD upload limits |
| Custom Fields | Contact fields, ticket fields | Data types, picklist values, usage rates |
| Brands | Count, separate inboxes/domains | Whether brands map to SWSD groups or sites |

### Define Migration Scope

Not everything needs to move. Common exclusions:

- **Spam tickets** — Filter these out before extraction.
- **Resolved tickets older than 2–3 years** — Consider archiving instead of migrating.
- **Test/internal tickets** — Clean up sandbox data.
- **Unused custom fields** — Audit field usage before recreating in SWSD.
- **Forum posts and feedback boards** — SWSD has no equivalent. Archive separately.
- **Deleted tickets** — Often better archived than migrated into an ITSM target.

For guidance on what to keep and what to leave, see our [Help Desk Data Migration Playbook](https://clonepartner.com/blog/blog/help-desk-data-migration-playbook/).

### Cutover Strategy

Choose a cutover pattern early:

- **Big bang:** Migrate everything in a single cutover window. Works for small datasets (< 20K records). Requires a maintenance window.
- **Phased:** Migrate by department, region, or date range. Reduces risk but requires running both systems in parallel.
- **Incremental (delta):** Migrate historical data first, then do a delta sync of records created/modified between the initial migration and cutover. Best for large datasets where downtime is unacceptable.

For zero-downtime approaches, see our guide on [zero-downtime help desk migration](https://clonepartner.com/blog/blog/zero-downtime-help-desk-data-migration/).

## Data Model and Object Mapping

The mapping decisions here determine whether the migration is useful after go-live.

### Tickets → Incidents

LiveAgent tickets become SWSD incidents or service requests. The mapping decision depends on your ITIL categorization:

- **Support issues, bug reports, outage reports** → Incidents
- **How-to questions, access requests, hardware requests** → Service Requests (via the SWSD Service Catalog)

<cite index="61-1,61-2">In SWSD, tickets fit under one of two classifications: incidents or service requests. An incident is any unexpected interference to a service, disrupting normal operations.</cite> <cite index="61-8">A service request is a request from a user for information, advice, a standard change, or access to a service.</cite>

LiveAgent ticket statuses (`New`, `Open`, `Answered`, `Resolved`, `Postponed`) must map to SWSD incident states (`New`, `Assigned`, `Awaiting Input`, `Resolved`, `Closed`). Note that `Awaiting Input` and `On Hold` are not default SWSD states in all configurations — verify they exist in your SWSD instance or create them as custom states before migration. LiveAgent's chat sessions, emails, and social media DMs all become standard incidents in SWSD — the platform has no native concept of a "WhatsApp" or "Facebook" channel ticket. Map the LiveAgent channel source to a custom SWSD field if you need to preserve that context.

### Contacts → Users

LiveAgent contacts become SWSD users with the **Requester** role. <cite index="22-5">Help desk contacts store essential information about customers, including their name, email, phone number, and other personal information.</cite>

> [!WARNING]
> **Critical constraint:** <cite index="76-7">SWSD incidents require a registered `requester_email` on create.</cite> All contacts must be loaded as SWSD users *before* you create any incidents. Violating this order results in API errors on every incident creation call. LiveAgent allows contacts to exist with minimal data, but SWSD requires structured user profiles — ensure all LiveAgent contacts have valid email addresses before extraction. Contacts without email addresses must be assigned to a fallback "Legacy Migration User" (see Edge Cases).

### Companies → Departments, Sites, or Custom Fields

SWSD does not have a "Company" object equivalent to LiveAgent's. Use this decision tree:

- **Use SWSD Departments** if LiveAgent companies map to internal organizational units (e.g., "Engineering," "Marketing," "HR"). Departments in SWSD control assignment routing and reporting segmentation.
- **Use SWSD Sites** if LiveAgent companies map to physical locations (e.g., "New York Office," "London HQ"). Sites are available on Business and Professional plans.
- **Use a custom field on the User record** if LiveAgent companies represent external customers (e.g., "Acme Corp," "Widget Inc.") that do not correspond to any internal SWSD organizational structure. This is the most common scenario for teams migrating from customer-facing support.

LiveAgent lets each contact belong to only one company, while SWSD does not give you a direct company object on the requester record. If company identity matters for reporting, store it explicitly using whichever approach matches your organizational model.

### Tags → Categories

LiveAgent tags are flat, free-form labels. SWSD categories are hierarchical (parent → child). You need to:

1. Export all LiveAgent tags and their usage frequency.
2. Decide which tags become SWSD categories, which become subcategories, and which get dropped.
3. Create the category hierarchy in SWSD before loading incidents.
4. Map tag names to category IDs in your transformation script.
5. Store overflow tags in a custom field or in `tag_list` if you need to preserve all of them.

### Knowledge Base → Solutions

LiveAgent knowledge base articles map to SWSD Solutions. Both support HTML content, but the organizational structure differs. LiveAgent uses portals with categories; SWSD uses a flat solutions list with categories.

**Visibility mapping:** LiveAgent KB articles can be public (customer-facing) or internal (agent-only). SWSD Solutions have corresponding visibility settings — `public` (visible in the self-service portal) and `internal` (visible only to agents). Map LiveAgent's article visibility to the matching SWSD Solution visibility during transformation. Verify visibility settings after loading by spot-checking both the agent interface and the self-service portal.

Rewrite any internal links that referenced LiveAgent URLs.

### Chat Transcripts and Call Recordings

LiveAgent stores chat transcripts as part of ticket messages. These get flattened into SWSD incident comments. Call recordings migrate as file attachments. Real-time metadata (typing indicators, visitor page history, chat ratings) has no SWSD equivalent and is lost.

**Chat-only tickets without contacts:** LiveAgent can create tickets from anonymous chat sessions where no email address was captured and no contact record exists. These tickets cannot be assigned to a valid SWSD requester. Handle them by assigning to the fallback "Legacy Migration User" and prepending any available session data (visitor name, IP, chat start time) to the incident description.

If channel attribution matters, store the original channel as a prefix in each comment body (e.g., `[CHAT]`, `[EMAIL]`, `[CALL]`).

### Custom Fields

LiveAgent supports custom fields on tickets and contacts. SWSD supports custom fields on incidents and users, but they must be strictly typed (e.g., Dropdown, Date, Text). <cite index="24-5">LiveAgent enables you to create custom contact fields to store unique information about contacts, such as shoe size, car model, billing address, account number, or birthday.</cite>

You must pre-create these custom fields in the SWSD admin panel before running the API migration. SWSD does not support creating custom field definitions via the API — they must be configured manually in Settings → Custom Fields. Once created, note the field ID from the API response when listing custom fields (`GET /custom_fields.json`), and reference that ID in your incident/user payloads during loading.

Verify data type compatibility — LiveAgent text fields mapping to SWSD dropdown fields require picklist value creation in advance. Audit custom field usage and values before migration, not during.

## Migration Architecture

### Data Flow

```
LiveAgent API v3           Staging DB           SolarWinds Service Desk API
─────────────────         ───────────         ──────────────────────────────
GET /tickets        →     PostgreSQL    →     POST /users.json
GET /customers      →     (transform,   →     POST /incidents.json
GET /companies      →      validate,    →     POST /incidents/{id}/comments.json
GET /tags           →      deduplicate)  →     POST /solutions.json
GET /knowledgebase  →                   →     POST /incidents/{id}/attachments
```

Do not attempt to transform and load in the same active memory thread. If the script crashes, you lose your place. Store raw JSON in a staging database so you can replay from any point.

### API Authentication

**LiveAgent:** API key passed in the `apikey` header. <cite index="5-1">The API key is an identity of your LiveAgent account which you can use to build custom integration solutions.</cite> Generate keys at Configuration > System > API.

**SWSD:** Token-based authentication. <cite index="17-4,17-5">Service Desk provides token-based authentication that encrypts credentials and enhances security while enabling API use.</cite> <cite index="17-8">Only someone with a Service Desk administrator license can generate an API token.</cite> The token is passed as `X-Samanage-Authorization: Bearer [TOKEN]`. If the admin user is disabled or the token is reset, the integration breaks.

### Timestamp Backdating

To preserve original creation dates on migrated incidents, pass the `created_at` field in your incident payload with the original LiveAgent timestamp in ISO 8601 format. The SWSD API accepts `created_at` on incident creation when the authenticated user has administrator permissions. **Test this explicitly in your sandbox** — if backdating is rejected (the API silently ignores the field or returns the current timestamp), your historical data will lose temporal ordering, which is a critical fidelity issue. If backdating is not supported for your plan tier, document this limitation and store the original creation date in a custom field as a workaround.

### Rate Limits

| Platform | Rate Limit | Pagination |
|---|---|---|
| LiveAgent | <cite index="33-1">180 requests per minute per API key</cite> | `_page` and `_perPage` (max 1,000 rows per response) |
| SWSD | Varies by plan tier; monitor `X-RateLimit-Remaining` and `Retry-After` response headers | `page` and `per_page` (default 25, max 100) |

SWSD rate limits are not published per plan tier in their documentation. Monitor the `X-RateLimit-Remaining` header on every response to track your remaining quota, and implement automatic backoff when it approaches zero. If you hit a 429 status code, read the `Retry-After` header and pause execution. Failing to back off will result in temporary blocks.

<cite index="73-11">SWSD returns total count in the `X-Total-Count` response header</cite>, which you can use to calculate the total number of pages needed.

<cite index="29-17,29-18,29-19">The LiveAgent GET tickets API returns a maximum of 1,000 rows per call.</cite> The `/tickets` list endpoint is also capped at 10,000 records per filter, so large extracts must be windowed by date range — monthly, weekly, or daily slices depending on volume.

> [!NOTE]
> **Throughput math:** At 180 req/min from LiveAgent with 50 tickets per page, you extract metadata for ~9,000 tickets per minute. But individual message retrieval requires one additional call per ticket, cutting effective throughput to ~90 tickets/min (metadata + messages). For 100K tickets, extraction alone takes ~18.5 hours minimum. Factor in attachment downloads and the total extraction phase for a 100K-ticket account can exceed 24 hours.

<cite index="35-3,35-4">Cloud-hosted LiveAgent accounts have rate limits to prevent server overload.</cite> <cite index="35-9,35-10">Standalone installations have the same rate limiter, but you can override it by inserting a row into the `qu_g_settings` table.</cite> If you run a self-hosted LiveAgent instance, increasing the rate limit before migration can speed up extraction.

### Attachment Size Mismatch

SWSD enforces a 25 MB per-file attachment limit. LiveAgent cloud ticket attachments have a similar cap, but chat attachments can reach 128 MB. Files exceeding the SWSD limit must be externalized — archive them in cloud storage (S3, Azure Blob) and insert a download link in the incident comment body. Log all oversized files for post-migration review.

## Step-by-Step Migration Process

### Step 1: Extract Data from LiveAgent

Use the API v3 to paginate through all objects. <cite index="51-14,51-15,51-16">You can use API v3 to export data — the call gives you tickets per page and allows you to set time ranges for which tickets to export. Once the page gets less than the max results, move on to the next day.</cite>

```python
import requests
import time
import json

LA_BASE = "https://yourdomain.ladesk.com/api/v3"
API_KEY = "your-api-key-here"
headers = {"apikey": API_KEY}

def extract_tickets(start_date, end_date):
    """Extract tickets within a date window to stay under 10K filter cap."""
    tickets = []
    page = 1
    while True:
        params = {
            "_perPage": 50,
            "_page": page,
            "_filters": json.dumps([
                ["date_created", "D>", f"{start_date} 00:00:00"],
                ["date_created", "D<", f"{end_date} 23:59:59"]
            ])
        }
        resp = requests.get(f"{LA_BASE}/tickets", headers=headers, params=params)
        if resp.status_code == 429:
            time.sleep(60)
            continue
        resp.raise_for_status()
        data = resp.json()
        if not data:
            break
        tickets.extend(data)
        if len(data) < 50:
            break  # last page
        page += 1
        time.sleep(0.35)  # stay under 180 req/min
    return tickets

def extract_messages(ticket_id):
    """Retrieve all messages for a single ticket."""
    resp = requests.get(
        f"{LA_BASE}/tickets/{ticket_id}/messages",
        headers=headers
    )
    resp.raise_for_status()
    return resp.json()

def extract_contacts():
    """Paginate through all customer contacts."""
    contacts = []
    page = 1
    while True:
        resp = requests.get(
            f"{LA_BASE}/customers",
            headers=headers,
            params={"_perPage": 50, "_page": page}
        )
        resp.raise_for_status()
        data = resp.json()
        if not data:
            break
        contacts.extend(data)
        if len(data) < 50:
            break
        page += 1
        time.sleep(0.35)
    return contacts
```

<cite index="31-1,31-2,31-3,31-4">If you prefer a database dump over API export, LiveAgent can provide one — you need to provide secure SSH/SCP access or they can share via a Google Drive download link.</cite> This is faster for very large accounts but requires working with raw database tables. <cite index="31-7,31-8">Database dumps contain raw data and LiveAgent recommends working with the API instead.</cite>

### Step 2: Transform Data

Transformation handles schema mapping, data cleaning, and deduplication.

**Key transformations:**

- **Status mapping:** LiveAgent `New` → SWSD `New`. LiveAgent `Open` → SWSD `Assigned`. LiveAgent `Answered` → SWSD `Awaiting Input`. LiveAgent `Resolved` → SWSD `Resolved`. LiveAgent `Postponed` → SWSD `On Hold`. Note: `Awaiting Input` and `On Hold` may be custom states in your SWSD instance — create them before migration and verify via `GET /incidents/states.json`.
- **Priority mapping:** LiveAgent priorities (1–3 stars or custom) → SWSD priority levels (Low, Medium, High, Critical). Map 1-star → Low, 2-star → Medium, 3-star → High. If you use a Critical priority, assign it based on a tag or custom field rule.
- **Tag → Category:** Flatten multi-tag tickets into a primary category. Store additional tags in a custom field.
- **Department → Group:** Map LiveAgent department IDs to SWSD group IDs.
- **Contact deduplication:** LiveAgent may create a new contact for each unique email-channel combination. A customer who emails and chats may have two contact records. SWSD users are unique by email — merge duplicates before loading, keeping the record with the most complete data (most fields populated, most recent activity).
- **ID swapping:** Swap LiveAgent Contact IDs for newly created SWSD User IDs using your mapping table.

```python
STATUS_MAP = {
    "N": "New",        # LiveAgent "New"
    "T": "Assigned",   # LiveAgent "Open" / "To solve"
    "A": "Awaiting Input",  # LiveAgent "Answered" — verify this state exists
    "R": "Resolved",   # LiveAgent "Resolved"
    "P": "On Hold",    # LiveAgent "Postponed" — requires custom state
}

PRIORITY_MAP = {
    "1": "Low",
    "2": "Medium",
    "3": "High",
}

def transform_ticket_to_incident(ticket, category_map, contact_to_user_map):
    """Map a LiveAgent ticket to a SWSD incident payload."""
    contact_email = ticket.get("contact_email", "")
    if not contact_email or contact_email not in contact_to_user_map:
        contact_email = "legacy-migration-user@yourdomain.com"

    messages = ticket.get("messages", [])
    description = messages[0]["body"] if messages else ""

    primary_tag = ticket.get("tags", ["General"])[0] if ticket.get("tags") else "General"

    return {
        "incident": {
            "name": ticket.get("subject") or "No Subject",
            "description": description,
            "requester": {"email": contact_email},
            "state": STATUS_MAP.get(ticket.get("status"), "New"),
            "priority": PRIORITY_MAP.get(str(ticket.get("priority", "1")), "Low"),
            "category": {"name": category_map.get(primary_tag, "General")},
            "created_at": ticket["date_created"],
            "custom_fields_values": {
                "source_liveagent_id_1234": str(ticket["id"]),
                "source_channel_5678": ticket.get("channel", "unknown"),
            }
        }
    }
```

### Step 3: Load Users into SWSD

Create all users (former LiveAgent contacts and agents) before creating incidents. Disable email notifications during bulk loads to avoid flooding inboxes — in SWSD, navigate to Setup → Notification Settings and disable incident creation notifications before the import run.

```python
SWSD_BASE = "https://api.samanage.com"
SWSD_TOKEN = "your-jwt-token"
swsd_headers = {
    "X-Samanage-Authorization": f"Bearer {SWSD_TOKEN}",
    "Content-Type": "application/json",
    "Accept": "application/vnd.samanage.v2.1+json"
}

def create_swsd_user(contact, id_map):
    """Create a user in SWSD and store the ID mapping."""
    email = contact["email"].lower().strip()

    # Check if already created (idempotency)
    if email in id_map:
        return id_map[email]

    payload = {
        "user": {
            "name": contact.get("name") or "Unknown",
            "email": email,
            "phone": contact.get("phone", ""),
            "role": {"name": "End User"}
        }
    }
    resp = requests.post(
        f"{SWSD_BASE}/users.json",
        headers=swsd_headers,
        json=payload
    )
    if resp.status_code == 200:
        swsd_user = resp.json()
        id_map[email] = swsd_user["id"]
        return swsd_user["id"]
    else:
        raise Exception(f"User creation failed for {email}: {resp.status_code} {resp.text}")
```

Store a mapping table of `LiveAgent_Contact_ID` → `SWSD_User_ID` (keyed by email). This table is essential for every subsequent step.

### Step 4: Load Incidents into SWSD

```python
def create_swsd_incident(incident_payload):
    """Create an incident in SWSD and return the new ID."""
    resp = requests.post(
        f"{SWSD_BASE}/incidents.json",
        headers=swsd_headers,
        json=incident_payload
    )
    if resp.status_code == 200:
        return resp.json()["id"]
    else:
        raise Exception(f"Incident creation failed: {resp.status_code} {resp.text}")
```

Capture the returned SWSD Incident IDs and store in your mapping table.

### Step 5: Load Comments and Attachments

After creating each incident, add subsequent messages as comments:

```python
def add_comment(incident_id, message, channel_prefix=""):
    """Add a comment to an existing SWSD incident."""
    body = message["body"]
    if channel_prefix:
        body = f"[{channel_prefix}] {body}"

    payload = {
        "comment": {
            "body": body,
            "is_private": not message.get("is_public", True)
        }
    }
    resp = requests.post(
        f"{SWSD_BASE}/incidents/{incident_id}/comments.json",
        headers=swsd_headers,
        json=payload
    )
    if resp.status_code not in (200, 201):
        raise Exception(f"Comment failed on incident {incident_id}: {resp.status_code}")
```

For attachments, download each file from LiveAgent (separate API call per file) and upload to the corresponding SWSD incident using multipart/form-data. Track file sizes — if a file exceeds SWSD's 25 MB limit, archive it externally and add a link in the comment body.

### Step 6: Load Knowledge Base Articles as Solutions

LiveAgent KB articles map to SWSD Solutions:

```python
def create_solution(article, visibility_map):
    """Create a Solution in SWSD from a LiveAgent KB article."""
    # Map LiveAgent visibility to SWSD
    is_internal = article.get("visibility") == "internal"

    payload = {
        "solution": {
            "name": article["title"],
            "content": article["body_html"],
            "visibility": "internal" if is_internal else "public"
        }
    }
    resp = requests.post(
        f"{SWSD_BASE}/solutions.json",
        headers=swsd_headers,
        json=payload
    )
    if resp.status_code not in (200, 201):
        raise Exception(f"Solution failed: {resp.status_code} {resp.text}")
```

Verify HTML rendering in the SWSD Solutions interface after loading. Check both the agent view and the self-service portal for public articles.

### Error Handling and Logging

Every API call should log the request, response status, and response body. Implement:

- **Retry with exponential backoff** for 429 (rate limit) and 5xx errors. Start with 1-second delay, double on each retry, cap at 60 seconds, max 5 retries.
- **Idempotency tracking** — store a mapping of LiveAgent IDs to SWSD IDs so you can resume after failures without creating duplicates.
- **Error quarantine** — log failed records to a separate file for manual review rather than stopping the entire migration.
- **Structured logging** — for every write, log source ID, target ID, batch ID, payload hash, retry count, and final result. Treat validation mismatches as hard stops, not warnings.

## Edge Cases and Challenges

### The "System User" Fallback

In LiveAgent, a ticket might be created via a channel that did not capture an email address (e.g., a Twitter DM, an anonymous chat, or a phone call without caller ID). SWSD will reject an incident payload if the `requester_id` is null or invalid.

**Solution:** Create a generic "Legacy Migration User" in SWSD (e.g., `legacy-migration-user@yourdomain.com`). During transformation, if a LiveAgent ticket lacks a valid contact email, assign it to this fallback user and prepend the original contact info (name, channel, timestamp) into the incident description so the context is not lost. Tag these incidents with a `missing_requester` flag for post-migration review.

### Duplicate Contacts

LiveAgent creates a new contact for each unique email-channel combination. A customer who emails and chats may have two contact records with the same email. SWSD enforces unique emails per user.

**Solution:** Before loading, group all LiveAgent contacts by email (lowercased, trimmed). For each group with multiple records, merge into a single user record: keep the name from the most recently active record, concatenate phone numbers if different, merge custom field values preferring non-null values. Log all merges for audit.

### Multi-Channel Tickets

A single LiveAgent ticket can contain email messages, chat transcripts, and call recordings in one thread. SWSD incidents do not distinguish message channels within comments. Channel metadata is lost unless you store the original channel as a prefix in each comment body (e.g., `[CHAT] 2024-03-15 14:22 — Customer: I can't log in`).

### Inline Images in HTML

LiveAgent emails often contain inline base64 images or hosted images. When pushed to SWSD, these image links will break if they point back to a secure LiveAgent instance that gets deprecated.

**Solution:** Parse the HTML of LiveAgent messages, identify `<img>` tags, download the assets, upload them to SWSD as attachments, and rewrite the HTML `src` attributes in the payload before posting the comment. For base64-encoded images, decode and upload as separate attachment files.

### Custom Field Drift

Option values rarely match cleanly between systems. LiveAgent text fields mapping to SWSD dropdown fields require picklist value creation in advance. Audit custom field usage and values before migration, not during.

### Reference Data Errors

One misspelled site or department value can create bad routing or failed updates in SWSD. Validate all reference data against the exact values created in SWSD before loading any records. Pull the canonical list via `GET /departments.json` and `GET /sites.json` and compare programmatically.

### Ticket Merge History

LiveAgent supports merging tickets. Merged tickets reference a parent ticket. SWSD does not support merged incident records during import. If a LiveAgent ticket was merged, extract the full merged thread and load it as a single SWSD incident with all messages from all original tickets as sequential comments.

## Limitations and Constraints

### SolarWinds Service Desk Limitations

- **No true custom objects.** SWSD has a fixed schema (incidents, problems, changes, assets, CMDB). You cannot create entirely new object types.
- **Category hierarchy is rigid.** SWSD categories follow a parent-child structure. LiveAgent's flat tag model does not map cleanly.
- **No community forums.** LiveAgent's forums and feedback boards have no equivalent.
- **Per-page limit on list endpoints.** <cite index="76-10">SWSD list endpoints use per_page with a max of 100.</cite> Large imports require careful pagination.
- **CSV import limitations.** CSV incident import only brings in text — not attachments or screenshots. Sites and departments cannot be imported via CSV. Exported SWSD CSVs are not valid import templates. Updating custom fields via CSV is not supported.
- **Custom fields cannot be created via API.** They must be configured manually in the SWSD admin panel before migration.
- **Sandbox provisioning.** SWSD does not provide a self-service sandbox. Contact SolarWinds support to request a sandbox instance for migration testing, or use a separate trial account. Do not test against production.

### LiveAgent Export Limitations

- <cite index="29-17,29-18,29-19">The GET tickets API returns a maximum of 1,000 rows per call.</cite> Use date-range filtering and pagination to work around this.
- The `/tickets` list endpoint caps at 10,000 records per filter. Window extraction by date range.
- CSV export captures metadata snapshots but not message bodies or attachments.
- <cite index="31-7,31-8">Database dumps contain raw data and LiveAgent recommends working with the API instead.</cite>

### Data That Cannot Be Migrated

| Data Type | Reason |
|---|---|
| Chat visitor metadata (pages viewed, browser, geolocation) | No SWSD equivalent |
| Ticket merge history | SWSD does not support merged records on import |
| SLA timing data (time-to-first-response, resolution clock) | SWSD SLAs start fresh; cannot import elapsed timers |
| Agent notes on contacts | Must be mapped to SWSD user custom fields or incident comments |
| Automation rules | Must be rebuilt manually in SWSD |
| Chat widget and IVR configurations | No migration path |
| Forum posts and feedback boards | No SWSD equivalent; archive separately |
| Call metadata (hold time, transfer count, IVR path) | Lost; audio file migrates as attachment only |

## Validation and Testing

### Record Count Validation

After loading, compare counts across every object type:

| Object | LiveAgent Count | SWSD Count | Delta | Action |
|---|---|---|---|---|
| Contacts/Users | X | Y | X - Y | Investigate missing records; check dedup log |
| Tickets/Incidents | X | Y | X - Y | Check error quarantine log |
| Messages/Comments | X | Y | X - Y | Verify per-incident; check first-message-as-description offset |
| KB Articles/Solutions | X | Y | X - Y | Manual review |
| Attachments | X | Y | X - Y | Reprocess failures; check 25 MB exclusion log |

### Field-Level Validation

Sample 50–100 records across each object type and verify:
- Subject/name matches
- Status/state mapping is correct
- Requester email links to the correct user
- `created_at` timestamps match source (or document the delta if backdating was not supported)
- Custom field values transferred correctly
- Attachment count per incident matches source
- Long message threads did not get truncated
- Private comments remain private
- Solution visibility (public vs. internal) matches source KB article visibility

### UAT Process

1. Migrate a test batch (500–1,000 records) to a SWSD sandbox environment.
2. Have agents verify 20–30 specific tickets they remember — check that message threads are complete and correctly attributed.
3. Validate that knowledge base articles render correctly in both the agent view and self-service portal.
4. Test that SWSD automations trigger correctly on migrated incidents.
5. Confirm that SLA policies apply as expected to newly created incidents.
6. Verify that the fallback "Legacy Migration User" incidents are identifiable and tagged for review.

### Rollback Planning

SWSD does not support bulk rollback. Your strategy:

1. **Keep LiveAgent active** until migration is fully validated. Do not cut mail routing until delta counts are clean.
2. **Tag all migrated records** in SWSD with a consistent marker (e.g., a `migrated_from_liveagent` tag or custom field) so you can identify and bulk-delete if needed.
3. **Maintain the ID mapping table** so you can trace any SWSD record back to its LiveAgent source.

For a detailed post-migration QA process, see our [Post-Migration QA Checklist](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

## Post-Migration Tasks

Once the data is verified in production, finalize the configuration.

### Rebuild Automations and Workflows

SWSD automations are structurally different from LiveAgent rules. You must manually recreate:

- **Ticket routing rules** → SWSD assignment rules based on category, group, or requester
- **SLA rules** → SWSD SLA policies (configured per priority and category, with business hours defined fresh). Review your LiveAgent SLA targets and recreate equivalent thresholds — e.g., if LiveAgent had a 4-hour first-response SLA for priority 3, configure the same in SWSD under Setup → SLA → add policy with matching priority and time target.
- **Auto-response templates** → SWSD email notification templates
- **Escalation rules** → SWSD escalation policies
- **Tag-based automation** → SWSD category-based or custom-field-based triggers

See our guide on [migrating automations and workflows](https://clonepartner.com/blog/blog/how-to-migrate-automations-macros-workflows/) for patterns that apply across platforms.

### Email Routing

Update your DNS and mail server settings to route support emails away from LiveAgent and into the SWSD drop box. Only switch routing after validation is complete. Keep LiveAgent receiving emails in parallel for at least 48 hours after cutover to catch any routing lag.

### User Training

The ITIL paradigm shift is significant. Your team needs training on:

- **Incident vs. service request classification** — concepts that do not exist in LiveAgent.
- **Problem management** — linking recurring incidents to root-cause problems.
- **Change management workflows** — approval processes, CAB reviews.
- **CMDB usage** — linking incidents to configuration items.
- **Self-service portal** — different from LiveAgent's customer portal.

### Post-Migration Monitoring

Plan a 2-week hypercare window after go-live. Run daily audits for the first week, then weekly for the first month:

- Check for orphaned incidents (no requester linked).
- Verify comment ordering on high-volume tickets.
- Confirm that attachment links are accessible.
- Monitor for misrouted incidents and bad requester mapping.
- Watch SWSD error logs for API-related issues.
- Spot-check 10 incidents per day against LiveAgent source to verify content integrity.

## Best Practices

1. **Back up the source first.** Export a full LiveAgent database dump before starting. <cite index="51-8">Besides the CSV export option, LiveAgent offers the ability to request a database dump from their administration team.</cite> If you need raw historical extraction, request it early — allow 3–5 business days for LiveAgent to process the request.
2. **Run a sandbox migration first.** Always execute a full test run into a SWSD sandbox environment. Never push directly to production on the first attempt.
3. **Pre-create reference data.** Create SWSD sites, departments, categories, custom fields, and custom states before loading any records. These cannot be created inline during import.
4. **Freeze the schema.** Do not allow admins to add new custom fields in LiveAgent or SWSD while the migration scripts are being written and tested.
5. **Incremental validation.** Validate after each batch (users, then incidents, then comments) rather than waiting until the end.
6. **Automate ID mapping.** Maintain a persistent lookup table mapping LiveAgent IDs to SWSD IDs. This is essential for relationship rebuilding, deduplication on retry, and debugging.
7. **Plan for the delta.** Extract the bulk of your history over a week. On cutover weekend, only extract tickets modified since the initial extraction date.
8. **Turn off notifications during bulk loads.** SWSD will send emails for every incident creation unless you disable notifications first. Re-enable immediately after loading completes.
9. **Document field mapping decisions.** Every mapping choice (especially lossy ones like tag → category) should be documented for audit purposes.
10. **Preserve source IDs.** Store the original LiveAgent ticket ID in a SWSD custom field. This is essential for idempotent re-runs and post-migration debugging.

## Sample Data Mapping Table

| LiveAgent Field | SWSD Field | Type | Transformation |
|---|---|---|---|
| `ticket.id` | custom field `source_liveagent_id` | String | Store for idempotent re-runs |
| `ticket.code` | custom field `source_ticket_code` | String | Preserve user-facing reference |
| `ticket.subject` | `incident.name` | String | Direct copy; normalize empty subjects to "No Subject" |
| `ticket.status` | `incident.state` | Enum | Map: New→New, Open→Assigned, Answered→Awaiting Input, Resolved→Resolved, Postponed→On Hold (custom) |
| `ticket.priority` | `incident.priority` | Enum | Map: 1-star→Low, 2-star→Medium, 3-star→High |
| `ticket.date_created` | `incident.created_at` | DateTime | ISO 8601 format; test backdating in sandbox first |
| `ticket.department_id` | `incident.assignee.group` | Reference | Map via group lookup table |
| `ticket.tags []` | `incident.category.name` | String→Enum | Primary tag → category; overflow → custom field |
| `ticket.channel` | custom field `source_channel` | String | Preserve original channel (email, chat, phone, social) |
| `contact.email` | `user.email` | String | Lowercase, trim, deduplicate |
| `contact.firstname` + `lastname` | `user.name` | String | Concatenate with space |
| `contact.phone` | `user.phone` | String | Direct copy |
| `contact.company_id` | `user.department` or custom field | Reference | Map per company→dept/site/custom decision tree |
| `contact.custom_fields` | `user.custom_fields` | Various | Create field definitions in SWSD admin panel first |
| `message.body` | `comment.body` | HTML | Sanitize HTML, preserve formatting, rewrite image links |
| `message.type` (internal) | `comment.is_private` | Boolean | Internal notes → private comments |
| `message.channel` | Comment body prefix | String | Prepend `[CHAT]`, `[EMAIL]`, `[CALL]` if channel attribution needed |
| `message.attachments []` | `incident.attachments []` | File | Download from LA, upload to SWSD if ≤ 25 MB; externalize otherwise |
| `kb_article.title` | `solution.name` | String | Direct copy |
| `kb_article.body` | `solution.content` | HTML | Rewrite internal links pointing to LiveAgent URLs |
| `kb_article.visibility` | `solution.visibility` | Enum | Map: public→public, internal→internal |

## Automation Script Outline

High-level structure for a Python-based migration pipeline:

```python
import requests
import time
import json
import logging
from datetime import datetime, timedelta

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)


class LiveAgentExtractor:
    """Handles paginated extraction from LiveAgent API v3."""
    def __init__(self, base_url, api_key):
        self.base_url = base_url
        self.headers = {"apikey": api_key}
        self.request_count = 0

    def _throttled_get(self, url, params=None):
        """GET with rate limiting (180 req/min)."""
        self.request_count += 1
        resp = requests.get(url, headers=self.headers, params=params)
        if resp.status_code == 429:
            logger.warning("Rate limited, sleeping 60s")
            time.sleep(60)
            return self._throttled_get(url, params)
        resp.raise_for_status()
        time.sleep(0.35)
        return resp.json()

    def extract_all_contacts(self) -> list:
        contacts, page = [], 1
        while True:
            data = self._throttled_get(
                f"{self.base_url}/customers",
                params={"_perPage": 100, "_page": page}
            )
            if not data:
                break
            contacts.extend(data)
            if len(data) < 100:
                break
            page += 1
        logger.info(f"Extracted {len(contacts)} contacts")
        return contacts

    def extract_tickets(self, date_from, date_to) -> list:
        """Window by date to stay under 10K filter cap."""
        all_tickets = []
        current = datetime.strptime(date_from, "%Y-%m-%d")
        end = datetime.strptime(date_to, "%Y-%m-%d")
        while current < end:
            window_end = min(current + timedelta(days=30), end)
            page = 1
            while True:
                params = {
                    "_perPage": 100,
                    "_page": page,
                    "_filters": json.dumps([
                        ["date_created", "D>", current.strftime("%Y-%m-%d 00:00:00")],
                        ["date_created", "D<", window_end.strftime("%Y-%m-%d 23:59:59")]
                    ])
                }
                data = self._throttled_get(f"{self.base_url}/tickets", params=params)
                if not data:
                    break
                all_tickets.extend(data)
                if len(data) < 100:
                    break
                page += 1
            logger.info(f"Window {current.date()}–{window_end.date()}: {len(all_tickets)} total")
            current = window_end
        return all_tickets

    def extract_messages(self, ticket_id) -> list:
        return self._throttled_get(f"{self.base_url}/tickets/{ticket_id}/messages")

    def download_attachment(self, url) -> bytes:
        resp = requests.get(url, headers=self.headers)
        resp.raise_for_status()
        return resp.content


class DataTransformer:
    """Maps LiveAgent objects to SWSD schema."""
    STATUS_MAP = {"N": "New", "T": "Assigned", "A": "Awaiting Input", "R": "Resolved", "P": "On Hold"}
    PRIORITY_MAP = {"1": "Low", "2": "Medium", "3": "High"}

    def __init__(self, category_map):
        self.category_map = category_map

    def deduplicate_contacts(self, contacts) -> list:
        by_email = {}
        for c in contacts:
            email = (c.get("email") or "").lower().strip()
            if not email:
                continue
            if email not in by_email or (c.get("date_changed", "") > by_email[email].get("date_changed", "")):
                by_email[email] = c
        logger.info(f"Deduplicated {len(contacts)} contacts to {len(by_email)}")
        return list(by_email.values())

    def transform_contact_to_user(self, contact) -> dict:
        return {
            "user": {
                "name": contact.get("name") or "Unknown",
                "email": contact["email"].lower().strip(),
                "phone": contact.get("phone", ""),
                "role": {"name": "End User"}
            },
            "_source_id": contact.get("id")
        }

    def transform_ticket_to_incident(self, ticket, contact_email_fallback) -> dict:
        email = (ticket.get("contact_email") or "").lower().strip()
        if not email:
            email = contact_email_fallback
        messages = ticket.get("messages", [])
        primary_tag = (ticket.get("tags") or ["General"])[0]
        return {
            "incident": {
                "name": ticket.get("subject") or "No Subject",
                "description": messages[0]["body"] if messages else "",
                "requester": {"email": email},
                "state": self.STATUS_MAP.get(ticket.get("status"), "New"),
                "priority": self.PRIORITY_MAP.get(str(ticket.get("priority", "1")), "Low"),
                "category": {"name": self.category_map.get(primary_tag, "General")},
                "created_at": ticket.get("date_created"),
            },
            "_source_id": ticket.get("id"),
            "_messages": messages[1:] if len(messages) > 1 else []
        }

    def transform_message_to_comment(self, message, channel_prefix="") -> dict:
        body = message["body"]
        if channel_prefix:
            body = f"[{channel_prefix}] {body}"
        return {
            "comment": {
                "body": body,
                "is_private": not message.get("is_public", True)
            }
        }


class SWSDLoader:
    """Handles creation of records in SolarWinds Service Desk."""
    MAX_RETRIES = 5

    def __init__(self, base_url, token):
        self.base_url = base_url
        self.headers = {
            "X-Samanage-Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "application/vnd.samanage.v2.1+json"
        }
        self.id_map = {}

    def _post_with_retry(self, url, payload):
        delay = 1
        for attempt in range(self.MAX_RETRIES):
            resp = requests.post(url, headers=self.headers, json=payload)
            if resp.status_code == 429:
                wait = int(resp.headers.get("Retry-After", delay))
                logger.warning(f"SWSD rate limit, waiting {wait}s (attempt {attempt+1})")
                time.sleep(wait)
                delay = min(delay * 2, 60)
                continue
            if resp.status_code >= 500:
                logger.warning(f"SWSD 5xx, retrying in {delay}s")
                time.sleep(delay)
                delay = min(delay * 2, 60)
                continue
            return resp
        raise Exception(f"Max retries exceeded for {url}")

    def create_user(self, user_data) -> str:
        email = user_data["user"]["email"]
        if email in self.id_map:
            return self.id_map[email]
        resp = self._post_with_retry(f"{self.base_url}/users.json", user_data)
        if resp.status_code == 200:
            uid = resp.json()["id"]
            self.id_map[email] = uid
            return uid
        raise Exception(f"User create failed: {resp.status_code} {resp.text}")

    def create_incident(self, incident_data) -> str:
        resp = self._post_with_retry(f"{self.base_url}/incidents.json", incident_data)
        if resp.status_code == 200:
            return resp.json()["id"]
        raise Exception(f"Incident create failed: {resp.status_code} {resp.text}")

    def add_comment(self, incident_id, comment_data):
        resp = self._post_with_retry(
            f"{self.base_url}/incidents/{incident_id}/comments.json", comment_data
        )
        if resp.status_code not in (200, 201):
            raise Exception(f"Comment failed: {resp.status_code}")

    def upload_attachment(self, incident_id, file_bytes, filename):
        if len(file_bytes) > 25 * 1024 * 1024:
            logger.warning(f"Skipping {filename} ({len(file_bytes)} bytes) — exceeds 25 MB limit")
            return None
        # Use multipart upload — headers differ for file upload
        headers = {k: v for k, v in self.headers.items() if k != "Content-Type"}
        resp = requests.post(
            f"{self.base_url}/incidents/{incident_id}/attachments.json",
            headers=headers,
            files={"file": (filename, file_bytes)}
        )
        return resp


class MigrationOrchestrator:
    """Coordinates the full ETL pipeline with error handling."""
    FALLBACK_EMAIL = "legacy-migration-user@yourdomain.com"

    def __init__(self, extractor, transformer, loader):
        self.extractor = extractor
        self.transformer = transformer
        self.loader = loader
        self.error_log = []

    def run(self, date_from="2020-01-01", date_to="2025-12-31"):
        # Phase 1: Extract
        logger.info("Phase 1: Extracting contacts")
        raw_contacts = self.extractor.extract_all_contacts()

        logger.info("Phase 2: Deduplicating and transforming contacts")
        contacts = self.transformer.deduplicate_contacts(raw_contacts)
        users = [self.transformer.transform_contact_to_user(c) for c in contacts]

        # Phase 3: Load users (must complete before incidents)
        logger.info(f"Phase 3: Loading {len(users)} users")
        for user in users:
            try:
                self.loader.create_user(user)
            except Exception as e:
                self.error_log.append({"type": "user", "source_id": user.get("_source_id"), "error": str(e)})

        # Phase 4: Extract and load tickets
        logger.info("Phase 4: Extracting tickets")
        tickets = self.extractor.extract_tickets(date_from, date_to)
        logger.info(f"Extracted {len(tickets)} tickets, loading incidents")

        for ticket in tickets:
            try:
                # Fetch messages
                messages = self.extractor.extract_messages(ticket["id"])
                ticket["messages"] = messages

                # Transform
                incident_data = self.transformer.transform_ticket_to_incident(ticket, self.FALLBACK_EMAIL)
                remaining_messages = incident_data.pop("_messages")
                source_id = incident_data.pop("_source_id")

                # Create incident
                swsd_id = self.loader.create_incident(incident_data)
                logger.info(f"Ticket {source_id} → Incident {swsd_id}")

                # Add comments
                for msg in remaining_messages:
                    channel = msg.get("channel", "")
                    comment = self.transformer.transform_message_to_comment(msg, channel)
                    self.loader.add_comment(swsd_id, comment)

                    # Attachments
                    for att in msg.get("attachments", []):
                        file_bytes = self.extractor.download_attachment(att["url"])
                        self.loader.upload_attachment(swsd_id, file_bytes, att["name"])

            except Exception as e:
                self.error_log.append({"type": "ticket", "id": ticket.get("id"), "error": str(e)})
                logger.error(f"Failed ticket {ticket.get('id')}: {e}")

        # Phase 5: Report
        logger.info(f"Migration complete. Errors: {len(self.error_log)}")
        with open("errors.json", "w") as f:
            json.dump(self.error_log, f, indent=2)
```

For full auditability, keep raw source payloads, transformed payloads, and the source-to-target crosswalk in separate database tables or JSON files.

## What to Expect

This migration is medium-to-high complexity. Expect 2–3 weeks for environments under 50,000 records with a dedicated engineer. Larger datasets (100K+) or environments with deep custom field usage and multi-channel ticket

## Frequently asked questions

### Can I migrate LiveAgent tickets to SolarWinds Service Desk using CSV only?

Partially. LiveAgent supports CSV export for ticket metadata, and SWSD supports CSV import for incidents — but CSV only brings in text, not attachments, screenshots, or individual message threads. LiveAgent CSV export captures metadata but not message bodies. For full data fidelity, use the LiveAgent API v3 for extraction and the SWSD REST API for loading.

### What are the API rate limits for this migration?

LiveAgent's API rate limit is 180 requests per minute per API key for cloud-hosted accounts. The GET tickets endpoint returns a maximum of 1,000 rows per call and caps at 10,000 records per filter — use date-range windowing for large exports. SWSD rate limits vary by plan tier; monitor the response headers and implement exponential backoff on 429 responses. Self-hosted LiveAgent instances can override rate limits via the qu_g_settings database table.

### How do LiveAgent objects map to SolarWinds Service Desk?

Tickets map to Incidents or Service Requests. Contacts become Users with the Requester role. Companies map to Departments, Sites, or custom fields (SWSD has no Company object). Tags map to hierarchical Categories (not 1:1 — requires planning). Knowledge Base articles become Solutions. Chat transcripts are flattened into incident comments, losing real-time metadata.

### How long does a LiveAgent to SolarWinds Service Desk migration take?

For environments under 50,000 records with a dedicated engineer, expect 2–3 weeks including extraction, transformation, loading, and validation. Larger datasets (100K+ records) with complex custom fields and multi-channel histories typically take 4–8 weeks. A managed migration service can compress this timeline.

### What data is lost when migrating from LiveAgent to SolarWinds Service Desk?

Chat visitor metadata (pages viewed, browser info), SLA timing data, ticket merge history, forum/feedback board content, chat widget configurations, and automation rules cannot be migrated. Call recordings transfer as file attachments but lose call-specific metadata. Channel indicators (WhatsApp, Facebook, etc.) are lost unless stored in a custom field. All automations must be rebuilt manually in SWSD.
