---
title: "Enchant to Deskpro Migration: A Technical Guide"
slug: enchant-to-deskpro-migration-a-technical-guide
date: 2026-07-24
author: Roopi
categories: [Migration Guide, Enchant]
excerpt: "A technical guide to migrating from Enchant to Deskpro, covering API constraints, object mapping, dependency order, rate limits, and validation steps."
tldr: Enchant to Deskpro migration requires API-to-API scripting — Deskpro's CSV importer only handles first messages. Map objects in strict dependency order and validate in a sandbox before cutover.
canonical: https://clonepartner.com/blog/enchant-to-deskpro-migration-a-technical-guide/
---

# Enchant to Deskpro Migration: A Technical Guide


# Enchant to Deskpro Migration: A Technical Guide

Moving from [Enchant](https://clonepartner.com/help-desk-data-migration/enchant) to [Deskpro](https://clonepartner.com/help-desk-data-migration/deskpro) means transitioning from a lightweight shared inbox to a full-featured helpdesk with departments, organizations, SLAs, custom fields, and a deeper CRM layer. The core challenge isn't conceptual — it's structural. Enchant's data model is intentionally minimal, while Deskpro enforces strict relational dependencies between People, Organizations, and Tickets. Your migration code has to bridge that gap without losing conversation history, customer relationships, or attachments.

Deskpro's native CSV importer can load users, organizations, and simple tickets, but it only imports ticket properties and the first message — no conversation threads, no attachments, no notes. For any real migration, you need the API. ([support.deskpro.com](https://support.deskpro.com/en-US/kb/articles/can-i-import-data-from-other-systems-or-helpdesks-1))

This guide covers the practical steps, dependency order, object mapping, API constraints on both sides, and the edge cases that break naive migrations.

## Overview of the Data Model Shift

Enchant is inbox-centric. Its core objects are tickets, messages, customers, contacts, users, inboxes, labels, and attachments. A ticket always belongs to an inbox. Customers are lightweight — they mainly carry identity and contact points. Enchant does not support custom fields on tickets; it uses labels for categorization and a free-text `summary` field on customer records. ([dev.enchant.com](https://dev.enchant.com/api/v1))

Deskpro is department-centric. Every ticket belongs to a **Department**, and tickets require strict associations with **Agents** and **People** (customers). Deskpro supports **Organizations** (company-level groupings of People), custom fields on tickets, users, and organizations, and granular ticket statuses including `awaiting_agent`, `awaiting_user`, `resolved`, and `archived`. ([support.deskpro.com](https://support.deskpro.com/en-US/guides/admin-guide/what-are-departments))

Companies typically move to Deskpro for:

- **On-premise deployment options** for data sovereignty requirements
- **Complex routing** with skill-based and department-layered ticket assignment
- **Multi-brand support** managing different customer bases from a single instance
- **Stronger department and form design** for larger teams

The asymmetry in custom field support actually simplifies migration in one direction: you won't lose structured data coming from Enchant, but you also can't magically create it. If you want to leverage Deskpro's custom fields, build them before import and decide what Enchant metadata (labels, customer summary) should populate them.

> [!WARNING]
> If your Enchant workspace shows account, order, or opportunity data in the sidebar, audit the source system. Enchant's docs describe sidebar apps as data pulled from internal systems at view time — that data lives in your CRM, not in Enchant. Do not try to recreate it as fake objects in Deskpro. ([help.enchant.com](https://help.enchant.com/article/112/enhance-customer-profiles-with-data-from-your-internal-systems))

## Define Your Migration Scope

Before writing migration code, decide exactly what needs to move and what gets rebuilt manually in Deskpro.

**Migrate via API:**
- **Customers** → Deskpro People
- **Contacts** (email, phone, Twitter) → Deskpro Contact Data
- **Organizations** (derived from customer email domains or CRM data) → Deskpro Organizations
- **Tickets** (all types) → Deskpro Tickets
- **Messages** (replies and notes) → Deskpro Messages/Notes
- **Attachments** → Deskpro Blob Attachments
- **Labels** → Deskpro Labels

**Rebuild manually in Deskpro:**
- Inboxes → Departments
- Agent accounts → Agent profiles
- Canned responses → Snippets
- SLA policies
- Triggers, automations, and workflows
- Knowledge base articles (Enchant KB has no bulk export API endpoint)

**Consider archiving rather than migrating:**
- Spam and trashed tickets
- Very old tickets with no operational value
- Snoozed tickets that have expired relevance

> [!TIP]
> Enchant stores tickets in states including `open`, `hold`, `closed`, `snoozed`, and `archived`. If your compliance requirements don't mandate full history, skip archived and trashed tickets to reduce migration time and keep your Deskpro instance clean.

For related guidance on scoping decisions, see our [Help Desk Data Migration Playbook](https://clonepartner.com/blog/blog/help-desk-data-migration-playbook/).

## Migration Approaches

There are five distinct ways to move your data. Each carries specific engineering trade-offs.

### 1. Native CSV Export/Import

Export report data from Enchant as CSV, normalize columns to match Deskpro's schema, and upload via Deskpro's web interface. ([help.enchant.com](https://help.enchant.com/article/119/export-report-data))

- **When to use it:** Low volume, low fidelity requirements, and you can accept partial history.
- **Pros:** Lowest engineering effort. Fast to pilot.
- **Cons:** Deskpro's CSV ticket import is intentionally limited — it imports ticket properties and the first message only, not full message history or attachments. Relational integrity is nearly impossible to maintain manually. ([support.deskpro.com](https://support.deskpro.com/en-US/kb/articles/can-i-import-data-from-other-systems-or-helpdesks-1))
- **Complexity:** Low.

### 2. API-Based Migration (Direct Scripting)

Write custom scripts (Python, Node.js) to extract data from Enchant's REST API and push it to Deskpro's v2 API. ([dev.enchant.com](https://dev.enchant.com/api/v1))

- **When to use it:** When you need real conversation history, attachments, deterministic mapping, or a delta sync phase. Works for teams with engineering bandwidth and datasets from hundreds to hundreds of thousands of records.
- **Pros:** Full control over data formatting, relationship mapping, and validation.
- **Cons:** High engineering overhead. You own rate limit handling, retry logic, error logging, and edge case resolution.
- **Complexity:** High.

### 3. Third-Party Migration Tools

Off-the-shelf software that maps fields in a visual UI and runs the migration in the cloud. Deskpro publicly partners with Help Desk Migration, and Help Desk Migration lists both Enchant and Deskpro among supported platforms — but supported platform does not mean perfect field parity. ([support.deskpro.com](https://support.deskpro.com/en-US/kb/articles/can-i-import-data-from-other-systems-or-helpdesks-1))

- **When to use it:** Standardized data models with no custom objects or complex routing rules, and low engineering bandwidth.
- **Pros:** Fast setup. Usually includes retry, batching, and progress tracking.
- **Cons:** Black-box execution. If a record fails, debugging is difficult. Coverage for Enchant-specific edge cases varies by vendor.
- **Complexity:** Medium.

### 4. Custom ETL Pipeline (Managed Service)

Data is extracted into a staging database, transformed using SQL/JSONata, and loaded into Deskpro with automated retry logic and validation.

- **When to use it:** Enterprise datasets (20k+ records), strict compliance requirements, or when internal engineering bandwidth is zero.
- **Pros:** Best control and auditability. Handles edge cases natively. Easy to checkpoint, replay, and diff.
- **Cons:** Requires budget allocation.
- **Complexity:** Outsourced (low for you, high for the provider).

### 5. Middleware (Zapier, Make)

Using iPaaS tools to push data between systems via webhooks or API polling. Official Zapier integrations on both Enchant and Deskpro are event/action oriented, not historical replay oriented. ([help.enchant.com](https://help.enchant.com/article/172/integrating-enchant-with-zapier))

- **When to use it:** Forward sync after cutover, or short coexistence windows. **Not for historical migration.**
- **Pros:** Fast to assemble for low-volume event sync.
- **Cons:** Will struggle to preserve long threads, attachments, timestamps, and complex relationships at scale. Exorbitantly expensive for high-volume historical data.
- **Complexity:** Medium.

### Method Comparison Matrix

| Approach | Data Fidelity | Engineering Effort | Handles Attachments | Best For |
|---|---|---|---|---|
| **CSV Import** | Low | Low | No | Shallow reference data only |
| **API Scripting** | High | High | Yes | Full-history operational migration |
| **Third-Party Tools** | Medium-High | Low-Medium | Yes (with limits) | Standardized setups, low dev bandwidth |
| **Managed ETL** | Highest | Zero (for you) | Yes | Mid-market & enterprise |
| **Middleware** | Low-Medium | Medium | Poorly | Forward sync only |

### Recommended Choice by Volume

- **Small team (<1,000 tickets):** A custom script is manageable for a developer in 1–2 days. Rate limits won't be painful.
- **Mid-size (1,000–20,000 tickets):** Budget 1–2 weeks for script development, testing, and validation. Rate limits will require overnight extraction runs.
- **Enterprise (20,000+ tickets):** Strongly consider a [managed migration service](https://clonepartner.com/blog/blog/in-house-vs-outsourced-data-migration/). The engineering time, edge case handling, and validation overhead adds up quickly.

## Prepare Deskpro for Data Import

Your Deskpro environment must be configured as the receiving container before any data arrives. Importing tickets into a Deskpro instance that lacks the right departments, agents, or custom fields will produce validation errors or data landing in the wrong buckets.

### Create Departments

Enchant organizes tickets by **Inboxes**. Deskpro uses **Departments**. Create a Deskpro department for each Enchant inbox you intend to migrate. Record the department IDs — you need them when mapping `inbox_id` to `department` during the transform phase.

### Create Organizations

If you group customers by company, create Deskpro Organizations before importing People. You can derive organizations from customer email domains (e.g., all `@acme.com` customers → "Acme" organization) or from CRM data. Use the Deskpro API to create organizations:

```python
def create_organization(name, external_crm_id=None):
    payload = {"name": name}
    if external_crm_id:
        payload["fields"] = {
            "crm_id_field_id": {"value": external_crm_id}  # Use your actual field ID
        }
    resp = requests.post(f"{DESKPRO_BASE}/organizations", headers=dp_headers, json=payload)
    if resp.status_code == 201:
        return resp.json()["data"]["id"]
    elif resp.status_code == 400:
        # Organization may already exist — search by name
        search = requests.get(f"{DESKPRO_BASE}/organizations",
                              headers=dp_headers, params={"name": name})
        results = search.json().get("data", [])
        if results:
            return results[0]["id"]
    return None
```

Record the organization ID mapping — you'll need it when creating People to link them to their organization.

### Add Agents

Add each agent manually in Deskpro under **Admin > Agents > Agent Profiles**. You can bulk-invite agents by email or upload via CSV. Each agent must exist before you can assign them to migrated tickets. Map each Enchant `user_id` to the corresponding Deskpro `agent_id`.

### Set Up Custom Fields

If you plan to carry Enchant metadata into Deskpro custom fields (e.g., storing Enchant's customer `summary` in a custom user field, or preserving original ticket channel type), create those fields before import. Deskpro supports custom fields on tickets, users, and organizations with types including text, choice, date, number, and toggle.

When pushing data via the API, Deskpro expects custom field data keyed by the **numeric Deskpro Field ID**, not the field name. The payload structure uses a `fields` object where each key is the field ID:

```json
{
  "subject": "Order issue",
  "person": 42,
  "department": 3,
  "message": {"message": "Hello", "format": "html"},
  "fields": {
    "12": {"value": "email"},
    "15": {"value": "ENT-4521"},
    "18": {"value": "2024-01-15T10:30:00+0000"}
  }
}
```

In this example, field ID `12` might be "Original Channel Type" (text), field ID `15` "Enchant Ticket ID" (text), and field ID `18` "Original Created Date" (date). Look up field IDs via `GET /api/v2/ticket_custom_fields` after creating them in the admin panel.

### Create Labels

Enchant uses labels for categorization. Deskpro also supports labels on tickets. You can either pre-create labels or let them be created on the fly via the API during ticket import.

### Generate an API Key

In Deskpro, navigate to **Admin > Apps & Integrations > API Keys**. Create a new API key with API v2 access. For migration purposes, use a **superuser API key** so you can create tickets on behalf of any agent.

> [!WARNING]
> **Superuser API keys** in Deskpro are not tied to a single agent. They can impersonate any agent via the `X-DeskPRO-Agent-ID` header. This is required during migration to preserve the original agent assignment on tickets and messages.

## Data Model and Object Mapping

Mapping the data correctly is the difference between a usable helpdesk and a broken database.

### Object-Level Mapping

| Enchant Object | Deskpro Equivalent | Notes |
|---|---|---|
| Inbox | Department | 1:1 mapping by name or ID |
| User (Agent) | Agent | Must pre-exist in Deskpro |
| Customer | Person (User) | Deskpro requires a valid email |
| Contact | Person Email/Phone | Nested under person record |
| Customer email domain | Organization | Derived; group People by company domain |
| Ticket | Ticket | Map states, types, and assignments |
| Message (reply) | Ticket Message/Reply | Preserve direction (in/out) |
| Message (note) | Ticket Note | Internal notes via `is_note=true` |
| Attachment | Blob Attachment | Upload to temp blob first, then link to message |
| Label | Label | Tags on tickets |
| Customer Summary | Custom User Field | No direct equivalent; use a custom text field |

### Field-Level Mapping

| Enchant Field | Deskpro Field | Transform Required |
|---|---|---|
| `ticket.id` | `ticket.ref` or custom field | Store as reference; Deskpro assigns its own ID |
| `ticket.subject` | `ticket.subject` | Direct copy |
| `ticket.state` | `ticket.status` | Map values (see table below) |
| `ticket.type` | Custom field or label | Preserve original channel type |
| `ticket.inbox_id` | `ticket.department` | Use your inbox→department mapping |
| `ticket.user_id` | `ticket.agent` | Use your agent ID mapping |
| `ticket.customer_id` | `ticket.person` | Use migrated person ID |
| `ticket.label_ids` | `ticket.labels` | Map label names |
| `ticket.created_at` | `ticket.date_created` | ISO 8601 timestamp; ensure consistent timezone (see note below) |
| `message.body` | `message.message` | HTML content; check `htmlized` flag |
| `message.direction` | Message context | `in` = user reply, `out` = agent reply |
| `message.more_body` | Concatenate with `body` | Agent signatures — silently dropped if not concatenated |
| `customer.first_name` | `person.first_name` | Direct copy |
| `customer.last_name` | `person.last_name` | Direct copy |
| `customer.contacts [].value` | `person.primary_email` or phone | First email → primary; extras → additional contacts |

> [!NOTE]
> **Timezone handling:** Enchant returns timestamps in UTC (ISO 8601 with `Z` suffix). Deskpro accepts ISO 8601 timestamps with timezone offset (e.g., `2024-01-15T10:30:00+0000`). Always normalize timestamps to UTC before loading. If your Deskpro instance is configured for a different timezone, Deskpro will display the converted local time — but the stored value should be UTC to avoid shifted ticket chronology.

### Ticket State Mapping

Enchant and Deskpro use different terminology for ticket lifecycle. The right mapping depends on your workflow, but here is a reasonable starting point:

| Enchant State | Deskpro Status | Notes |
|---|---|---|
| `open` | `awaiting_agent` | Active, needs agent attention |
| `hold` | `awaiting_user` | Waiting for customer reply |
| `closed` | `resolved` | Resolved but visible |
| `archived` | `archived` | Long-term storage |
| `snoozed` | `awaiting_user` (with follow-up date) | Deskpro has no native snooze — use a follow-up date |

> [!NOTE]
> For `open` tickets, consider checking the last message direction. If the last public message was from the agent, `awaiting_user` may be more accurate than `awaiting_agent`. Decide this based on your operational model.

### What to Do with CRM Data

- **Accounts / companies:** If the source of truth is outside Enchant, map the company record to a Deskpro Organization and keep the CRM ID in a custom field on the organization record.
- **Leads:** If they were never actual support contacts, do not force them into Deskpro. Keep them in the CRM.
- **Opportunities / pipelines:** Deskpro does not have a native opportunities or pipeline layer. Treat these as external CRM records. ([support.deskpro.com](https://support.deskpro.com/en-US/guides/admin-guide/user-and-organization-fields))
- **Activities / tasks:** If they matter for support context, turn them into notes or link out to the system that owns them.

## Migration Architecture

The most resilient architecture is an **Extract → Transform → Load (ETL)** pipeline with a staging database.

### Enchant API (Source)

The Enchant API is accessed at `https://{site}.enchant.com/api/v1`. Authentication uses Bearer tokens. ([dev.enchant.com](https://dev.enchant.com/api/v1))

Key constraints:
- **Rate limit:** 100 credits per minute per account, with a burst limit of 6 requests per second
- **Pagination:** Maximum 100 results per page
- **Large datasets:** When retrieving more than 10,000 tickets, use `since_created_at` instead of page numbers
- **Embedding:** Related resources (messages, customer, labels) can be embedded in a single request, but each embed costs an additional rate limit credit
- **Attachments:** Returned as metadata only; file download requires a separate request per attachment

### Deskpro API (Target)

The Deskpro API v2 is accessed at `https://{instance}.deskpro.com/api/v2/`. Authentication uses API keys via the `Authorization: key {id}:{key}` header. ([support.deskpro.com](https://support.deskpro.com/pt-PT/kb/articles/basic-api-usage))

Key constraints:
- **Rate limits:** Configurable per API key (hourly/daily). Deskpro Cloud instances enforce a default limit per API key; contact Deskpro support to increase it for migration. On-premise deployments can set custom limits in the admin panel under API key settings.
- **Ticket creation:** Requires at minimum a `person` (email or person_id) and `message` payload. If you pass a `person_id` that does not exist, the ticket creation fails entirely with a 400 error
- **Attachment upload:** Two-step process — upload blob to `/blobs/temp` first (returns a `blob_auth` token), then reference that token in the message payload
- **Filtered queries cap at 1,000 results.** During validation, use unfiltered pagination or break queries into date ranges for accurate counts
- **Ticket list endpoints return active tickets by default.** During QA, explicitly request resolved or archived records via status filter or you will think data is missing when it is only filtered out

### ETL Flow

1. **Extract:** Query Enchant's `/api/v1/tickets` with Bearer token auth. Use `since_created_at` for datasets over 10,000 records. Use embeds to reduce round trips. Store raw JSON in a staging database (PostgreSQL, or SQLite for smaller datasets).
2. **Transform:** Map Enchant IDs to Deskpro structures. Download attachments from Enchant URLs and store them locally or in S3. Normalize HTML, merge duplicate customers, derive organizations from email domains, and derive Deskpro statuses from Enchant states. Normalize all timestamps to UTC ISO 8601 format.
3. **Load:** POST to Deskpro in strict dependency order:
   - First: `/api/v2/organizations`
   - Second: `/api/v2/people` (with `organization` ID linked)
   - Third: Upload attachments to `/api/v2/blobs/temp` to get blob auth tokens
   - Fourth: `/api/v2/tickets` with the new Person IDs, Department IDs, and blob references

> [!WARNING]
> **Never pipe data directly from API to API.** Network failures will cause data loss or duplication. A staging database lets you resume exactly where you left off.

## Step-by-Step Migration Process

### Step 1: Extract Customers from Enchant

Paginate through all customers using `since_created_at` for large datasets:

```python
import requests
import time

ENCHANT_BASE = "https://yoursite.enchant.com/api/v1"
ENCHANT_TOKEN = "your-bearer-token"
headers = {"Authorization": f"Bearer {ENCHANT_TOKEN}"}

def handle_rate_limit(resp):
    remaining = int(resp.headers.get("Rate-Limit-Remaining", 100))
    if remaining < 5:
        reset = int(resp.headers.get("Rate-Limit-Reset", 60))
        print(f"Rate limit low ({remaining} remaining). Sleeping {reset}s.")
        time.sleep(reset)

def extract_customers():
    customers = []
    params = {"per_page": 100, "sort": "created_at"}
    since = None

    while True:
        if since:
            params["since_created_at"] = since
        resp = requests.get(f"{ENCHANT_BASE}/customers", headers=headers, params=params)
        handle_rate_limit(resp)

        batch = resp.json()
        if not batch:
            break
        customers.extend(batch)
        since = batch[-1]["created_at"]
        time.sleep(0.2)  # Respect burst limit (6 req/s)

    return customers
```

### Step 2: Create Organizations in Deskpro

Derive organizations from customer email domains and create them before People:

```python
DESKPRO_BASE = "https://yourinstance.deskpro.com/api/v2"
DESKPRO_KEY = "key 1:YOUR_API_KEY"
dp_headers = {"Authorization": DESKPRO_KEY, "Content-Type": "application/json"}

def derive_organizations(customers, domain_exclusions=None):
    """Extract unique company domains from customer emails.
    Excludes common freemail domains (gmail, yahoo, etc.)."""
    if domain_exclusions is None:
        domain_exclusions = {
            "gmail.com", "yahoo.com", "hotmail.com", "outlook.com",
            "aol.com", "icloud.com", "mail.com", "protonmail.com"
        }

    domain_to_customers = {}
    for customer in customers:
        for contact in customer.get("contacts", []):
            if contact["type"] == "email":
                domain = contact["value"].split("@")[-1].lower()
                if domain not in domain_exclusions:
                    domain_to_customers.setdefault(domain, []).append(customer["id"])
    return domain_to_customers

def create_organizations_in_deskpro(domain_to_customers):
    org_map = {}  # domain -> deskpro_org_id
    for domain, customer_ids in domain_to_customers.items():
        org_name = domain.split(".")[0].capitalize()
        payload = {"name": org_name}
        resp = requests.post(f"{DESKPRO_BASE}/organizations", headers=dp_headers, json=payload)

        if resp.status_code == 201:
            org_id = resp.json()["data"]["id"]
        elif resp.status_code == 400:
            search = requests.get(f"{DESKPRO_BASE}/organizations",
                                  headers=dp_headers, params={"name": org_name})
            results = search.json().get("data", [])
            org_id = results[0]["id"] if results else None
        else:
            org_id = None

        if org_id:
            org_map[domain] = org_id
    return org_map
```

### Step 3: Load Customers into Deskpro as People

For each Enchant customer, create a person in Deskpro and link to their organization. Handle duplicates — Deskpro enforces unique email addresses:

```python
def create_person(customer, org_map):
    primary_email = None
    for contact in customer.get("contacts", []):
        if contact["type"] == "email":
            primary_email = contact["value"]
            break

    if not primary_email:
        return None  # Cannot create person without email

    payload = {
        "primary_email": primary_email,
        "first_name": customer.get("first_name", ""),
        "last_name": customer.get("last_name", ""),
    }

    # Link to organization based on email domain
    domain = primary_email.split("@")[-1].lower()
    if domain in org_map:
        payload["organization"] = org_map[domain]

    # Store Enchant customer summary in a custom user field
    if customer.get("summary"):
        payload["fields"] = {
            "25": {"value": customer["summary"]}  # Replace 25 with your actual field ID
        }

    resp = requests.post(f"{DESKPRO_BASE}/people", headers=dp_headers, json=payload)
    if resp.status_code == 201:
        return resp.json()["data"]["id"]
    elif resp.status_code == 400:
        # Person may already exist — search and retrieve
        search = requests.get(f"{DESKPRO_BASE}/people", headers=dp_headers,
                              params={"primary_email": primary_email})
        results = search.json().get("data", [])
        if results:
            return results[0]["id"]
    return None
```

### Step 4: Extract Tickets with Embedded Messages

Use Enchant's embed feature to pull messages alongside tickets, reducing API calls:

```python
def extract_tickets():
    tickets = []
    params = {"per_page": 100, "sort": "created_at", "embed": "messages"}
    since = None

    while True:
        if since:
            params["since_created_at"] = since
        resp = requests.get(f"{ENCHANT_BASE}/tickets", headers=headers, params=params)
        handle_rate_limit(resp)

        batch = resp.json()
        if not batch:
            break
        tickets.extend(batch)
        since = batch[-1]["created_at"]
        time.sleep(0.2)

    return tickets
```

> [!NOTE]
> **Embedding costs extra credits.** Each embedded resource type (messages, customer, labels) adds one credit to the request. A ticket request with `embed=messages,customer,labels` costs 4 credits total. At 100 credits/minute, that is only 25 requests per minute — plan your extraction time accordingly.

### Step 5: Upload Attachments to Deskpro

Attachments require a two-step process. Download from Enchant, then upload to Deskpro's temp blob endpoint:

```python
def download_enchant_attachment(attachment_url):
    """Download attachment content from Enchant."""
    resp = requests.get(attachment_url, headers=headers)
    if resp.status_code == 200:
        return resp.content
    return None

def upload_deskpro_blob(file_content, filename):
    """Upload file to Deskpro temp blob storage. Returns blob_auth token."""
    url = f"{DESKPRO_BASE}/blobs/temp"
    upload_headers = {"Authorization": DESKPRO_KEY}
    files = {"file": (filename, file_content)}

    response = requests.post(url, headers=upload_headers, files=files)
    if response.status_code == 201:
        return response.json()["data"]["blob_auth"]
    return None

def process_attachments(attachments):
    """Download from Enchant and upload to Deskpro. Returns list of blob_auth tokens."""
    blob_auths = []
    for att in attachments:
        content = download_enchant_attachment(att["url"])
        if content:
            blob_auth = upload_deskpro_blob(content, att["filename"])
            if blob_auth:
                blob_auths.append(blob_auth)
            else:
                log_error(f"Failed to upload attachment: {att['filename']}")
        else:
            log_error(f"Failed to download attachment: {att['url']}")
    return blob_auths
```

### Step 6: Load Tickets into Deskpro

For each Enchant ticket, create a Deskpro ticket with its first message, then add subsequent messages in chronological order:

```python
STATUS_MAP = {
    "open": "awaiting_agent",
    "hold": "awaiting_user",
    "closed": "resolved",
    "archived": "archived",
    "snoozed": "awaiting_user",
}

def map_status(enchant_state):
    return STATUS_MAP.get(enchant_state, "awaiting_agent")

def add_message(dp_ticket_id, msg, agent_map):
    """Add a reply or note to an existing Deskpro ticket."""
    is_note = msg.get("type") == "note"
    body = msg.get("body", "")

    # Concatenate signature if present
    if msg.get("more_body"):
        body = body + msg["more_body"]

    payload = {
        "message": body,
        "format": "html" if msg.get("htmlized") else "text",
        "date_created": msg.get("created_at"),
    }

    if is_note:
        payload["is_note"] = True

    # Handle attachments on this message
    if msg.get("attachments"):
        blob_auths = process_attachments(msg["attachments"])
        if blob_auths:
            payload["attachments"] = blob_auths

    # Set agent context for outbound messages
    msg_headers = dict(dp_headers)
    if msg.get("direction") == "out" and msg.get("user_id"):
        dp_agent_id = agent_map.get(msg["user_id"])
        if dp_agent_id:
            msg_headers["X-DeskPRO-Agent-ID"] = str(dp_agent_id)

    resp = requests.post(
        f"{DESKPRO_BASE}/tickets/{dp_ticket_id}/messages",
        headers=msg_headers, json=payload
    )
    if resp.status_code != 201:
        log_error(f"Failed to add message to ticket {dp_ticket_id}: {resp.text}")
    return resp.status_code == 201

def add_labels(dp_ticket_id, label_ids, label_name_map):
    """Add labels to a Deskpro ticket. label_name_map: enchant_label_id -> label_name"""
    for label_id in label_ids:
        label_name = label_name_map.get(label_id)
        if label_name:
            payload = {"label": label_name}
            resp = requests.post(
                f"{DESKPRO_BASE}/tickets/{dp_ticket_id}/labels",
                headers=dp_headers, json=payload
            )
            if resp.status_code not in (200, 201):
                log_error(f"Failed to add label '{label_name}' to ticket {dp_ticket_id}")

def log_error(message):
    """Append error to migration log file."""
    with open("migration_errors.log", "a") as f:
        f.write(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ')} | {message}\n")

def migrate_ticket(ticket, customer_map, agent_map, inbox_map, label_name_map):
    person_id = customer_map.get(ticket["customer_id"])
    if not person_id:
        log_error(f"No person mapping for customer {ticket['customer_id']}")
        return None

    messages = sorted(ticket.get("messages", []), key=lambda m: m["created_at"])
    first_msg = messages[0] if messages else None

    if not first_msg:
        log_error(f"Ticket {ticket['id']} has no messages")
        return None

    # Build first message body with signature
    first_body = first_msg.get("body", "")
    if first_msg.get("more_body"):
        first_body = first_body + first_msg["more_body"]

    # Handle attachments on the first message
    first_msg_attachments = []
    if first_msg.get("attachments"):
        first_msg_attachments = process_attachments(first_msg["attachments"])

    payload = {
        "person": person_id,
        "subject": ticket.get("subject", "(no subject)"),
        "department": inbox_map.get(ticket["inbox_id"]),
        "agent": agent_map.get(ticket.get("user_id")),
        "status": map_status(ticket["state"]),
        "date_created": ticket["created_at"],
        "message": {
            "message": first_body,
            "format": "html" if first_msg.get("htmlized") else "text"
        },
        "fields": {
            "15": {"value": str(ticket["id"])}  # Enchant ticket ID — use your field ID
        }
    }

    if first_msg_attachments:
        payload["message"]["attachments"] = first_msg_attachments

    resp = requests.post(f"{DESKPRO_BASE}/tickets", headers=dp_headers, json=payload)
    if resp.status_code != 201:
        log_error(f"Failed to create ticket {ticket['id']}: {resp.text}")
        return None

    dp_ticket_id = resp.json()["data"]["id"]

    # Add remaining messages as replies/notes
    for msg in messages[1:]:
        add_message(dp_ticket_id, msg, agent_map)

    # Add labels
    if ticket.get("label_ids"):
        add_labels(dp_ticket_id, ticket["label_ids"], label_name_map)

    return dp_ticket_id
```

> [!WARNING]
> **Verify historical date support against your Deskpro build.** Deskpro release notes show `date_created` support on ticket POSTs and ticket message POSTs, plus `date_resolved` on ticket POSTs. Older or customized deployments may differ. Test this in your sandbox before depending on it in production. ([support.deskpro.com](https://support.deskpro.com/en-US/news/posts/pdf/deskpro-2018-2-release))

### Step 7: Validate the Migration

After loading, compare record counts and verify data integrity programmatically:

```python
import random

def validate_migration(enchant_tickets, dp_ticket_map, customer_map, enchant_customers):
    """Full validation suite. dp_ticket_map: enchant_ticket_id -> deskpro_ticket_id"""
    errors = []

    # --- Record count comparison ---
    print(f"Enchant tickets extracted:  {len(enchant_tickets)}")
    print(f"Deskpro tickets created:    {len(dp_ticket_map)}")
    print(f"Delta (missing):            {len(enchant_tickets) - len(dp_ticket_map)}")
    print(f"Enchant customers:          {len(enchant_customers)}")
    print(f"Deskpro people created:     {len(customer_map)}")

    # --- Message count verification ---
    sample_size = min(50, len(dp_ticket_map))
    sampled_enchant_ids = random.sample(list(dp_ticket_map.keys()), sample_size)

    for enchant_id in sampled_enchant_ids:
        dp_id = dp_ticket_map[enchant_id]

        # Get source message count
        source_ticket = next(t for t in enchant_tickets if t["id"] == enchant_id)
        source_msg_count = len(source_ticket.get("messages", []))

        # Get Deskpro message count (paginate to handle >1000 edge case)
        dp_resp = requests.get(
            f"{DESKPRO_BASE}/tickets/{dp_id}/messages",
            headers=dp_headers, params={"count": 1000}
        )
        dp_msg_count = len(dp_resp.json().get("data", []))

        if source_msg_count != dp_msg_count:
            errors.append(
                f"Message count mismatch: Enchant #{enchant_id} has {source_msg_count} msgs, "
                f"Deskpro #{dp_id} has {dp_msg_count} msgs"
            )

    # --- Thread order verification ---
    for enchant_id in sampled_enchant_ids[:10]:
        dp_id = dp_ticket_map[enchant_id]
        dp_resp = requests.get(
            f"{DESKPRO_BASE}/tickets/{dp_id}/messages",
            headers=dp_headers, params={"order_by": "date_created", "order_dir": "asc"}
        )
        dp_messages = dp_resp.json().get("data", [])
        dp_dates = [m["date_created"] for m in dp_messages]
        if dp_dates != sorted(dp_dates):
            errors.append(f"Thread order broken on Deskpro ticket #{dp_id}")

    # --- Attachment size verification ---
    for enchant_id in sampled_enchant_ids[:20]:
        source_ticket = next(t for t in enchant_tickets if t["id"] == enchant_id)
        for msg in source_ticket.get("messages", []):
            for att in msg.get("attachments", []):
                # Verify attachment exists and is non-zero in Deskpro
                dp_id = dp_ticket_map[enchant_id]
                dp_msgs = requests.get(
                    f"{DESKPRO_BASE}/tickets/{dp_id}/messages", headers=dp_headers
                ).json().get("data", [])
                dp_attachment_count = sum(
                    len(m.get("attachments", [])) for m in dp_msgs
                )
                source_attachment_count = sum(
                    len(m.get("attachments", [])) for m in source_ticket.get("messages", [])
                )
                if dp_attachment_count != source_attachment_count:
                    errors.append(
                        f"Attachment count mismatch on ticket #{enchant_id}: "
                        f"source={source_attachment_count}, deskpro={dp_attachment_count}"
                    )
                break  # Only check first message with attachments per ticket

    # --- Status mapping verification ---
    for enchant_id in sampled_enchant_ids[:20]:
        dp_id = dp_ticket_map[enchant_id]
        source_ticket = next(t for t in enchant_tickets if t["id"] == enchant_id)
        expected_status = map_status(source_ticket["state"])
        dp_ticket = requests.get(
            f"{DESKPRO_BASE}/tickets/{dp_id}", headers=dp_headers
        ).json().get("data", {})
        actual_status = dp_ticket.get("status")
        if actual_status != expected_status:
            errors.append(
                f"Status mismatch on ticket #{enchant_id}: "
                f"expected={expected_status}, actual={actual_status}"
            )

    # --- Report ---
    if errors:
        print(f"\n{len(errors)} validation errors found:")
        for err in errors:
            print(f"  ✗ {err}")
    else:
        print("\n✓ All validation checks passed.")

    return errors
```

## Edge Cases and Failure Modes

The following table catalogs common failure modes, their root causes, and specific fixes:

| Symptom | Cause | Fix |
|---|---|---|
| Broken images in ticket body after Enchant shutdown | Inline `<img>` tags reference Enchant-hosted URLs | Parse HTML with BeautifulSoup, download each `src` image, upload to Deskpro `/blobs/temp`, rewrite `src` to new blob URL |
| Deskpro rejects ticket creation with 400 error | Duplicate email: Enchant allows multiple customer records with same email; Deskpro enforces unique emails | Deduplicate customers on email address (not name) in transform phase; merge all ticket references to single Deskpro Person ID |
| Ticket creation fails with "person not found" | Enchant ticket references a `customer_id` that was deleted or never had an email | Create a fallback "Unknown Customer" person in Deskpro (e.g., `unknown@yourdomain.com`); assign orphan tickets to this person |
| All historical tickets show as "email" type | Deskpro API ticket creation defaults to email channel | Store original Enchant channel type (`chat`, `sms`, `whatsapp`, `fb_messenger`, `instagram_dm`, `phone`, `call`) in a custom field or label |
| HTML tags displayed as literal text in agent UI | `format` parameter mismatch: sending HTML body with `format: "text"` | Check Enchant's `htmlized` boolean per message; set Deskpro `format` to `"html"` when `htmlized=true` |
| Agent signatures missing from outbound replies | Enchant stores signatures in `more_body` field, separate from `body` | Concatenate `body + more_body` before importing |
| Message appears authored by migration API key instead of original agent | Deskpro API actions execute in the authenticated agent's context | Use superuser API key with `X-DeskPRO-Agent-ID` header set to original agent's Deskpro ID |
| Customer replies show as agent-authored | Deskpro API may not support adding messages as end-user context in all builds | Preserve original sender email and timestamp in message metadata; if end-user context is unavailable, prepend message with "Originally from: customer@email.com" |
| Ticket timestamps all show import date instead of original date | Deskpro build does not support `date_created` on POST | Verify `date_created` support in sandbox first; if unsupported, store original date in a custom field |
| Extraction script crashes at record ~10,000 | Enchant page-based pagination breaks beyond 10,000 records | Switch to `since_created_at` cursor-based pagination |
| Message body contains malformed HTML | Source data has unclosed tags or invalid entities | Run through an HTML sanitizer (e.g., `bleach` or `lxml.html.clean`) before POSTing to Deskpro |
| Chat/SMS messages lose real-time threading context | Non-email channels in Enchant have rapid back-and-forth that displays differently when converted to ticket replies | Accept fidelity loss; add a note on the ticket indicating original channel and that message timing may differ from display |
| CSAT scores missing in Deskpro | Enchant satisfaction scores have no direct API migration path to Deskpro's feedback system | Export CSAT data separately as CSV for archival; optionally store the score in a custom ticket field for reference |
| `snoozed_until` timestamp lost | Deskpro has no native snooze; maps to `awaiting_user` | Store original `snoozed_until` value in a custom date field; set a Deskpro follow-up date if available |

### Rate Limit Math

Enchant's 100 credits/minute with a 6 requests/second burst means your theoretical max is 100 requests per minute (assuming 1 credit each). With embedded resources, that drops quickly. For a 10,000-ticket migration with messages embedded (2 credits per request), expect the extraction phase alone to take 7–10 hours at ~50 effective requests per minute. For 50,000 tickets, budget 35–50 hours of extraction time. Plan for overnight or weekend runs.

## Limitations and Constraints

**What Enchant does not give you:**
- No bulk export API — everything is paginated REST
- No custom fields on tickets (only labels and customer summary)
- No knowledge base export API endpoint
- Attachment content requires individual download per file
- No webhook history or automation export

**What Deskpro's CSV importer cannot do:**
- Cannot import message threads — only ticket properties and first message
- Cannot import attachments
- Cannot import agent notes
- Organization imports require at least one user email per organization

**Data you may lose in translation:**
- Enchant's `snoozed_until` timestamp — Deskpro has no native snooze equivalent (workaround: store in custom date field)
- Enchant's original ticket number — Deskpro assigns its own reference numbers (workaround: store in custom text field)
- CSAT data — Enchant's satisfaction scores do not have a direct migration path to Deskpro's feedback system (workaround: store score in custom number field)
- Conversation threading on non-email channels (chat, SMS) may lose real-time context
- End-user authorship context on messages may not be fully replayable via API in all Deskpro builds

## Validation and Testing

Never run a migration straight to production. Follow this sequence:

1. **Dry run on a test Deskpro instance.** Create a sandbox and migrate a representative subset (100–200 tickets across all inboxes, including tickets with attachments, notes, 10+ replies, and multiple channel types).
2. **Record-count comparison.** Verify customer count, organization count, ticket count, and total message count match between source and target.
3. **Field-level spot checks.** Sample 20–50 tickets and verify subject, status, assignment, labels, timestamps, custom field values, and full message threads.
4. **Attachment verification.** Confirm attachments are downloadable from Deskpro and match the originals in file size and content type.
5. **Thread integrity.** Check tickets with 10+ replies. Ensure chronological order is preserved and that agent replies vs. customer replies are attributed correctly.
6. **Inline image verification.** Open 5–10 tickets that contained inline images in Enchant. Confirm images render in the Deskpro agent UI, not as broken `<img>` tags.
7. **UAT with agents.** Have 2–3 agents review migrated tickets for their own assigned history. They will catch mapping errors that scripts will not.
8. **Rollback plan.** For Deskpro Cloud sandboxes, you can request a reset through Deskpro support. For on-premise instances, snapshot the database before migration and restore from snapshot to roll back. Document the exact rollback procedure and test it once before the production migration.

For a deeper validation checklist, see our [Post-Migration QA guide](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

## Post-Migration Tasks

Once data is validated, you still have work to do:

- **Rebuild automations.** Deskpro triggers and escalations must be configured from scratch. Enchant's automation rules do not export. See our [automation migration guide](https://clonepartner.com/blog/blog/how-to-migrate-automations-macros-workflows/).
- **Set up email routing.** Update your DNS and forwarding rules to point incoming support email at Deskpro instead of Enchant. Ensure you have disconnected them from Enchant to prevent loop conditions or missed tickets.
- **Configure SLA policies.** Recreate any SLA rules in Deskpro's admin panel.
- **Train your team.** Deskpro's interface and workflow model differs significantly from Enchant's shared inbox. Plan training sessions before cutover, focusing on department navigation and Snippets (macros).
- **Monitor for 72 hours.** Watch for tickets that arrive in both systems during the transition window. A brief period of dual-routing is normal — ensure nothing falls through.
- **Keep Enchant read-only** until rollback risk is acceptably low.

## Best Practices

- **Back up everything first.** Export a full data dump from Enchant via API before starting. Store raw JSON locally.
- **Use a staging database.** Never pipe data directly from API to API. A staging DB lets you resume exactly where you left off after failures.
- **Run at least two test migrations.** The first surfaces mapping errors. The second confirms your fixes work.
- **Use `since_created_at` for extraction.** Page-based pagination breaks beyond 10,000 records in Enchant.
- **Respect rate limits proactively.** Read the `Rate-Limit-Remaining` header on every response and sleep before hitting zero. Do not wait for 429 errors.
- **Log everything.** Every API call, every response code, every skipped record. You will need the audit trail when someone asks "where's ticket #4521?"
- **Migrate in dependency order.** Agents → Departments → Organizations → People → Tickets → Messages → Attachments. Breaking this order causes foreign key failures.
- **Preserve original IDs.** Store the Enchant ticket ID and customer ID in Deskpro custom fields for cross-referencing during validation.
- **Run a delta cutover.** Migrate historical data (older than 30 days) a week before go-live. On cutover day, run a delta script to catch only the tickets created or modified since the last extraction. This keeps your support team running with zero downtime.
- **Freeze config changes.** Do not add new custom fields or inboxes in Enchant during the migration window.
- **Normalize timestamps.** Convert all Enchant UTC timestamps to ISO 8601 with explicit timezone offset (`+0000`) before loading into Deskpro. Inconsistent timezone handling is a silent source of chronological errors.

## When to Use a Managed Migration Service

Building a migration script is manageable when the dataset is small and clean. It stops being manageable when:

- You discover 500 customers with duplicate email addresses
- Attachments fail silently because of encoding issues
- Your extraction script crashes at ticket 8,000 due to an unexpected null field
- A message body contains malformed HTML that breaks Deskpro's parser
- You spend more time on error handling than on the actual migration logic

The hidden cost of DIY migration is not the script — it is the debugging, validation, and re-running. Building an ETL pipeline for a one-time event is an inefficient use of senior engineering time.

At ClonePartner, we treat data migration as an engineering discipline. Our engineers build custom scripts per migration, handle rate limit orchestration, validate every record, and deliver a Migration Validation Report with full metrics. We handle conversion of Enchant Inboxes to Deskpro Departments, merge duplicate customers, derive Organizations from email domains, map domains to Organizations, and process attachments asynchronously to bypass rate limits. You get a fully validated Deskpro instance without pulling your engineers off core product work.

> Migrating from Enchant to Deskpro and want to skip the debugging? Our engineers handle extraction, transformation, loading, and validation — so your team can focus on what matters.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I use Deskpro's CSV importer to migrate from Enchant?

Only partially. Deskpro's CSV importer supports ticket properties and the first message only — it cannot import conversation threads, attachments, or agent notes. For a full migration with history, you need the Deskpro REST API v2. ([support.deskpro.com](https://support.deskpro.com/en-US/kb/articles/can-i-import-data-from-other-systems-or-helpdesks-1))

### Can I migrate attachments from Enchant to Deskpro?

Yes, but it requires a multi-step process: download the file from Enchant's API, upload it to Deskpro's /blobs/temp endpoint to get a blob auth token, then attach that token to the Deskpro ticket message payload.

### How do I handle duplicate customer emails during migration?

Deskpro enforces unique email addresses for the Person object. Enchant allows loose customer creation with potential duplicates. Your transform layer must deduplicate on email address and merge records before pushing to Deskpro to avoid API rejections.

### What is the Enchant API rate limit for data extraction?

Enchant's API is rate limited to 100 credits per minute per account, with a secondary burst limit of 6 requests per second. Embedding related resources (messages, customer, labels) costs additional credits per embed type, which reduces effective throughput.

### Will I lose my ticket creation dates when migrating to Deskpro?

Not necessarily. Deskpro's API supports a date_created parameter on ticket POSTs, but you should verify this works on your specific Deskpro build before depending on it. Older or customized deployments may behave differently. ([support.deskpro.com](https://support.deskpro.com/en-US/news/posts/pdf/deskpro-2018-2-release))
