---
title: "Zammad to Tidio Migration: A Technical Guide"
slug: zammad-to-tidio-migration-a-technical-guide
date: 2026-07-29
author: Nachi
categories: [Migration Guide, Tidio]
excerpt: "Technical guide for migrating from Zammad to Tidio. Covers data mapping, API rate limits, organization flattening, and step-by-step migration process."
tldr: "Zammad to Tidio migration requires flattening organizations into contact properties, replaying ticket articles as sequential replies, and handling Tidio's 60–120 rpm rate limits with no bulk import."
canonical: https://clonepartner.com/blog/zammad-to-tidio-migration-a-technical-guide/
---

# Zammad to Tidio Migration: A Technical Guide


Migrating from [Zammad](https://docs.zammad.org/en/latest/api/intro.html) to [Tidio](https://developers.tidio.com/) means moving from a full-featured, open-source helpdesk with deep organizational hierarchy to a lightweight, chat-first customer communication platform. The data models are fundamentally different, and that gap defines every decision in this migration.

Zammad organizes data around the **Ticket** and the **Organization**. Tidio organizes data around the **Contact** and the **Conversation**. That makes this a data-model redesign, not a simple export/import job.

This guide covers the object mapping, API constraints on both sides, viable migration methods, error states you will encounter, and the edge cases that will trip you up if you haven't done this before.

**Verified against:** Zammad 6.x REST API and Tidio OpenAPI as of July 2025. Zammad 5.x users should verify endpoint behavior — some pagination and search parameters differ between major versions.

## Core Architecture Differences

Zammad is built around a traditional helpdesk model: **Tickets → Articles → Users → Organizations → Groups**. It follows an "API First" philosophy — the UI itself is an API client running in the browser. Every operation available in the interface is accessible via the REST API at `/api/v1/`. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/intro.html))

Tidio is structured around real-time communication: **Contacts → Conversations → Tickets → Operators → Departments**. There is no dedicated Organization object. There is no knowledge base import pathway. The API requires a Plus or Premium plan and is rate-limited significantly more aggressively than Zammad's. ([developers.tidio.com](https://developers.tidio.com/docs/openapi-enable))

The biggest structural gaps:

- **Organizations don't exist in Tidio.** You must flatten organization data into Contact Properties.
- **Ticket Articles** (Zammad's per-message model) have no direct equivalent. You replay them as ticket replies sequentially.
- **Agents/Operators cannot be created via the Tidio API** — it's read-only for operator records. ([developers.tidio.com](https://developers.tidio.com/reference/get_operators))
- **Knowledge Base content has no import pathway** in Tidio. Archive it or recreate it manually.
- **Tidio `POST /contacts` always creates a new contact** and does not overwrite an existing one, even when the same email or `distinct_id` is sent. The API returns `201 Created` with a new `contact_id` — there is no error or deduplication warning. Use lookup + `PATCH`, or `/contacts/batch` for controlled updates. ([developers.tidio.com](https://developers.tidio.com/reference/post_contacts))
- **Merged tickets in Zammad** (state = `merged`) carry a `link` reference to their parent ticket. Tidio has no merge concept — you must decide whether to skip merged tickets, migrate them as standalone tickets, or append their articles to the parent ticket's reply chain.

> [!NOTE]
> If your Zammad instance has been acting as a lightweight CRM, do not assume Tidio should become the new system of record for accounts or custom objects. Tidio's public model does not expose first-class company or arbitrary custom-object endpoints. ([developers.tidio.com](https://developers.tidio.com/reference/get_tickets-custom-fields))

## Data Mapping Overview

| Zammad Object | Tidio Object | Transformation Notes |
|---|---|---|
| Users (Customers) | Contacts | Match on email. At least one of email, first_name, last_name, or phone required. Store Zammad user ID as `distinct_id` (max 55 chars). ([developers.tidio.com](https://developers.tidio.com/reference/post_contacts)) |
| User Custom Attributes | Contact Properties | Must pre-create property schema in Tidio admin before import. Types limited to: text, email, number, phone, url. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/5444927889052-Contact-Properties)) |
| Organizations | Contact Properties / Metadata | No Organization object in Tidio. Flatten org name, domain, notes to contact-level properties. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/organization.html)) |
| Groups | Departments | Manual creation required. API exposes department IDs for retrieval only. ([developers.tidio.com](https://developers.tidio.com/reference/get_departments)) |
| Agents / Admins | Operators | Manual creation required. Tidio API is read-only for operators. Budget ~2 minutes per operator for manual creation in the admin panel. |
| Tickets | Tickets | Map requester to Tidio Contact ID. Status mapping required: map to `open`, `pending`, `solved`. ([developers.tidio.com](https://developers.tidio.com/reference/patch_tickets-ticketid)) |
| Ticket Articles | Ticket Replies / Messages | Sequential replay via the Reply endpoint. Map `internal=true` → `message_type=internal`, `internal=false` → `message_type=public`. |
| Merged Tickets | No equivalent | Zammad state `merged` with parent link. Either skip, migrate as standalone, or append articles to parent ticket's reply chain. |
| Tags | Tags | Resolve tag names to Tidio tag IDs before load. ([developers.tidio.com](https://developers.tidio.com/reference/get_tickets-tags)) |
| Attachments | External links in message body | Download from Zammad, host externally, embed link in reply content. |
| Knowledge Base | No import pathway | Manual recreation or archive externally. |
| SLAs / Triggers / Macros | No import pathway | Rebuild in Tidio's Flows and workflow settings. |

> [!WARNING]
> **Organization flattening is irreversible in Tidio.** Because Tidio lacks a relational Organization object, updating a company name for one contact will not automatically update it for other contacts in the same company. Handle this via your CRM integration post-migration.

## Migration Approaches

### 1. Native Export/Import (CSV-Based)

This is a partial method, not a full-fidelity migration path. Zammad's built-in reporting can download ticket data as `.xlsx`, but the download is limited to 6,000 entries. Tidio can import contacts from UTF-8 CSV/TXT files and lets you choose whether to update existing contacts by email or skip them. ([admin-docs.zammad.org](https://admin-docs.zammad.org/en/6.3/manage/report-profiles.html))

- **How it works:** Export flat contact data from Zammad (available under Settings > Users > Import for user CSVs), normalize columns, then import into Tidio's Customers > All contacts import flow.
- **When to use it:** Small datasets, contact-only migrations, or a one-time reference archive where ticket history does not need to be live in Tidio.
- **Pros:** Fast, low engineering effort, good for contact cleanup.
- **Cons:** No clean path for threaded ticket articles, no attachment replay, no organizational relationships. Zammad's ticket CSV export does not include message bodies.
- **Scalability:** Small only (under 6,000 records).
- **Complexity:** Low.

### 2. API-Based Migration (Zammad REST API → Tidio OpenAPI)

This is the highest-fidelity route available from public docs. Zammad supports pagination with `page` and `per_page`, `expand=true` for richer payloads, and `full=true` on search endpoints for assets plus related objects. Zammad supports both Token and OAuth authentication; for migrations, Token auth is simpler and sufficient — OAuth adds complexity without benefit for batch extraction. Tidio exposes contacts, contact-property definitions, operators, departments, tickets, tags, ticket custom fields, replies, and webhooks through its OpenAPI. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/intro.html))

- **How it works:** Extract Zammad users, organizations, tickets, articles, tags, and attachments via REST. Pre-create Tidio contact properties and ticket custom fields in the admin panel. Pull Tidio departments, operators, tag IDs, and ticket custom-field definitions. Create or update contacts in batches (capped at 100 per request, all-or-nothing — if one record fails validation, the entire batch is rejected). Create tickets, patch status/priority/assignment, then replay replies in order. Store source IDs, reconciliation logs, and failures in a staging table. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/5444927889052-Contact-Properties))
- **When to use it:** Any migration involving ticket history, conversation threads, or custom field data. This is the primary viable approach for a complete migration.
- **Pros:** Full control over data mapping. Preserves ticket threading and conversation history. Easiest path to delta sync and repeatable cutover.
- **Cons:** Tidio's rate limits are aggressive — 60 requests per minute on Plus and 120 on Premium. ([developers.tidio.com](https://developers.tidio.com/docs/openapi-rate-limiting)) Zammad pagination has hard caps (typically 100 per request). Requires significant development effort for dedupe logic, field mapping, retries, and attachment handling.
- **Scalability:** Small to enterprise, provided you queue requests and respect plan limits.
- **Complexity:** High.

> [!CAUTION]
> Tidio `POST /contacts` always creates a new contact and does not overwrite an existing one, even when the same email or `distinct_id` is sent. The response is `201 Created` with a new UUID — no error, no warning. Use lookup + `PATCH`, or `/contacts/batch` for controlled updates, and keep `distinct_id` at 55 characters or less. If `distinct_id` exceeds 55 characters, the API returns `422 Unprocessable Entity` with a validation error on the `distinct_id` field. ([developers.tidio.com](https://developers.tidio.com/reference/post_contacts))

### 3. Custom ETL Pipeline

API migration with infrastructure: staging database, immutable source snapshots, object storage for attachments, worker queues, retry ledger, and QA queries.

- **How it works:** Extract Zammad data into a PostgreSQL staging database (or directly via API if cloud-hosted — Zammad Cloud does not expose direct database access). For self-hosted instances, direct database extraction bypasses API pagination limits and can be 10–50x faster for large datasets. Transform with versioned rules (dbt, SQL, or application code), and load through Tidio APIs. Validate from both ends.
- **When to use it:** Enterprise datasets (10K+ tickets), complex custom field mappings, compliance requirements, M&A consolidation, or when direct database access to a self-hosted Zammad instance offers faster extraction than the API.
- **Pros:** Maximum flexibility, best auditability, replay safety, rollback support.
- **Cons:** Highest build cost. Overkill for most standard helpdesk migrations.
- **Scalability:** Enterprise.
- **Complexity:** High.

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

Use this for **ongoing sync**, not for bulk backfill. Make currently lists Zammad as a verified app and Tidio as a community app. Zammad webhooks are triggered via Triggers or Schedulers and retry failed deliveries up to four times. ([make.com](https://www.make.com/en/integrations/zammad))

Zammad webhook payloads follow this general structure for ticket events:

```json
{
  "ticket": {
    "id": 123,
    "title": "Subject line",
    "state": "open",
    "customer_id": 456,
    "group_id": 7
  },
  "article": {
    "id": 789,
    "body": "Message content",
    "internal": false,
    "sender": "Customer"
  }
}
```

- **How it works:** Emit events from Zammad via webhooks or API polling, transform in middleware, then write to Tidio via API actions.
- **When to use it:** Phased cutover, bi-directional coexistence, or syncing only new tickets/contacts during a phased rollout.
- **Pros:** Quick to wire, less code, good observability.
- **Cons:** Cannot handle historical bulk migration effectively. No support for replaying ticket article threads at scale. Rate limits stack (both platform and API). Task costs add up quickly.
- **Scalability:** Delta sync only.
- **Complexity:** Medium.

### 5. Managed Migration Service

- **How it works:** A migration partner handles the entire pipeline — extraction, transformation, loading, validation, and rollback planning.
- **When to use it:** When engineering bandwidth is limited, the dataset is large, or accuracy is non-negotiable.
- **Pros:** No internal development time. Handles edge cases, rate limiting, and validation. Accountable for data integrity.
- **Cons:** External cost. Requires sharing API credentials.
- **Complexity:** Low (for your team).

Help Desk Migration's public supported-platform list includes both Zammad and Tidio. That said, "supports platform" does not mean "supports your custom fields, attachment rules, and organization flattening." Verify object coverage line by line before signing. ([help-desk-migration.com](https://help-desk-migration.com/supported-platforms/))

### Approach Comparison

| Method | Best For | Ticket History | Scale | Complexity | Risk |
|---|---|---|---|---|---|
| CSV Export | Contacts only, small datasets | ❌ No | Small (<6K records) | Low | Low |
| API-Based | Full migration with history | ✅ Yes | Small–Enterprise | High | Medium |
| Custom ETL | Large/complex self-hosted instances | ✅ Yes | Enterprise | High | Medium |
| Middleware (Zapier/Make) | Ongoing sync, simple contact moves | ❌ No | Delta only | Low–Medium | Low |
| Managed Service | Any scale, limited dev bandwidth | ✅ Yes | Any | Low (for you) | Low |

**Recommendations:**
- **Small business (<1,000 tickets):** Use CSV for contacts, keep Zammad in read-only mode for 90 days for historical reference.
- **One-time migration with ticket history:** API-based migration or a managed service.
- **Enterprise or regulated team:** Custom ETL or a managed engineer-led migration.
- **Ongoing sync:** Webhooks + middleware or direct API workers.

## Pre-Migration Planning

Before writing a single line of code, define the boundaries of your migration.

1. **Audit your Zammad data.** Count users, organizations, tickets, articles, tags, and custom attributes. Identify which custom object attributes exist on tickets, users, and organizations. For self-hosted Zammad, run direct SQL counts:
   ```sql
   SELECT COUNT(*) FROM users WHERE role_ids @> ARRAY [3]; -- Customers
   SELECT COUNT(*) FROM tickets;
   SELECT COUNT(*) FROM ticket_articles;
   SELECT COUNT(*) FROM organizations;
   ```
   For Zammad Cloud, paginate the API and count. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/ticket/index.html))

2. **Identify dead data.** Spam tickets, test users, orphaned organizations — exclude them from scope. Consider archiving tickets older than 3 years to a secure archive (e.g., AWS S3) instead of paying to store them in Tidio.

3. **Map custom attributes to Tidio fields.** Tidio Contact Properties support five types: text, email, number, phone, and url. Run a pre-migration attribute compatibility check:

   | Zammad Attribute Type | Tidio Compatible Type | Required Transformation |
   |---|---|---|
   | `input` (text) | `text` | Direct |
   | `select` | `text` | Convert to string value of selected option |
   | `tree_select` | `text` | Flatten to full path string (e.g., "Category > Subcategory") |
   | `boolean` | `text` | Convert to "true"/"false" string |
   | `integer` | `number` | Direct |
   | `datetime` | `text` | Convert to ISO 8601 string |

   Tidio ticket custom fields are separate from contact properties and serve different purposes: contact properties appear on the contact record and are visible in the contacts list, while ticket custom fields attach to individual tickets and do not appear in contact views. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/25942550979228-Custom-fields-for-tickets))

4. **Create Tidio operators and departments manually.** The Tidio API is read-only for operators. Every agent must be created through the Tidio admin panel before migration. Budget approximately 2 minutes per operator (name, email, password setup) and 1 minute per department. For a team of 50 operators and 20 departments, this is roughly 2 hours of manual work. ([developers.tidio.com](https://developers.tidio.com/reference/get_operators))

5. **Pre-create Contact Properties in Tidio.** Custom properties must be defined in Settings > Contact Properties before any contact data can reference them. Note: contact properties cannot currently be deleted, only hidden. This means any incorrectly named or typed property will persist in your schema. Plan your schema on paper first and have it reviewed before creating anything. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/5444927889052-Contact-Properties))

6. **Confirm your Tidio plan supports API access.** The OpenAPI requires a Plus or Premium plan. Rate limits: 60 rpm on Plus, 120 rpm on Premium. Free and Starter plans do not include OpenAPI access. ([developers.tidio.com](https://developers.tidio.com/docs/openapi-enable))

7. **Choose your cutover pattern:**
   - **Big bang:** Fastest, highest risk. Cut over in one weekend. Suitable for small datasets under 5,000 tickets.
   - **Phased:** Move contacts/tickets in waves by department or age band. Reduces blast radius but requires both systems to be active simultaneously.
   - **Incremental:** Full backfill first, then delta sync from webhooks/API until final switch. We recommend this approach — migrate 90% of historical data a week before go-live, then sync only the delta on cutover weekend. This eliminates the pressure of completing the entire migration in a single window.

## Migration Architecture

The standard architecture follows an Extract, Transform, Load (ETL) pattern.

1. **Extract** from Zammad with paged API calls using `expand=true`. Zammad paginates with `page` and `per_page` (max 100 per page), supports `full=true` on search endpoints, and exposes a download path for ticket attachments. For self-hosted instances running Zammad 6.x, you can also extract directly from the PostgreSQL database — this bypasses pagination limits and is significantly faster for datasets above 50,000 records. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/intro.html))

2. **Transform** into a staging schema that contains source IDs, target IDs, normalized status/priority, flattened organization data, and reply sequencing. Map Zammad states to Tidio's smaller set:

   | Zammad State | Tidio Status | Notes |
   |---|---|---|
   | `new` | `open` | |
   | `open` | `open` | |
   | `pending reminder` | `pending` | |
   | `pending close` | `pending` | |
   | `closed` | `solved` | |
   | `merged` | Skip or `solved` | See merged ticket handling below |

   Map priorities: `1 low` → `low`, `2 normal` → `normal`, `3 high` → `urgent`. Convert timestamps to ISO 8601. Sanitize HTML in article bodies — strip `<script>` tags, convert inline styles to plain text if Tidio's reply renderer doesn't support them. ([developers.tidio.com](https://developers.tidio.com/reference/patch_tickets-ticketid))

3. **Load** into Tidio through the OpenAPI with `X-Tidio-Openapi-Client-Id` and `X-Tidio-Openapi-Client-Secret` headers. Tidio requires you to create the Contact first, retrieve the Contact ID, create the Ticket associated with that ID, and then sequentially POST the replies to that Ticket ID. Contact batch create/update caps requests at 100 records and uses an all-or-nothing strategy — if any single record in the batch fails validation, the entire batch returns `422` and zero records are created. ([developers.tidio.com](https://developers.tidio.com/docs/openapi-authorization))

> [!WARNING]
> Tidio's OpenAPI endpoints are available on Plus and Premium plans, with published limits of 60 requests/minute on Plus and 120 requests/minute on Premium. Free and Starter plans do not include OpenAPI access. Budget your migration window around these limits before you promise a weekend cutover. For reference: 10,000 tickets with an average of 5 articles each requires ~60,000 API calls (contacts + tickets + replies + status patches). At 120 rpm, that is approximately 8.3 hours of continuous API calls assuming zero errors and zero retries. ([developers.tidio.com](https://developers.tidio.com/docs/openapi-enable))

## Step-by-Step Migration Process

### Step 1: Extract Data from Zammad

Use the Zammad REST API to pull all relevant objects. Authenticate with an API token (created under Profile > Token Access in Zammad):

```bash
# List all users (paginated)
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.com/api/v1/users?page=1&per_page=100&expand=true"

# List all tickets with nested articles
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.com/api/v1/tickets?page=1&per_page=100&expand=true"

# Get articles for a specific ticket
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.com/api/v1/ticket_articles/by_ticket/TICKET_ID"

# List all organizations
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.com/api/v1/organizations?page=1&per_page=100"

# Download an attachment from a ticket article
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.com/api/v1/ticket_attachment/TICKET_ID/ARTICLE_ID/ATTACHMENT_ID" \
  -o attachment_file.pdf
```

> [!WARNING]
> Zammad has hard limits on the maximum number of returned objects per request (100 for most endpoints). You cannot raise these limits. You must paginate through all pages using the `page` and `per_page` parameters. An empty array `[]` in the response indicates you have reached the last page.

### Step 2: Pre-Configure Tidio

- Create all **operators** manually in the Tidio admin panel. Record each operator's Tidio UUID — you'll need it for ticket assignment mapping.
- Create all **departments** manually. Pull their IDs via `GET /departments`.
- Define all **Contact Properties** that map to your Zammad custom user/organization attributes. Verify the type mapping from the attribute compatibility table above.
- Define all **ticket custom fields** you need for metadata like source ticket ID or original timestamps.
- Build an ID mapping table in your staging database:

```sql
CREATE TABLE id_map (
    source_system VARCHAR(20) NOT NULL,
    source_type VARCHAR(50) NOT NULL,
    source_id VARCHAR(100) NOT NULL,
    target_id VARCHAR(100),
    status VARCHAR(20) DEFAULT 'pending',
    error_message TEXT,
    created_at TIMESTAMP DEFAULT NOW(),
    PRIMARY KEY (source_system, source_type, source_id)
);
```

### Step 3: Create Contacts in Tidio

For each Zammad user (role = customer), create a contact in Tidio:

```bash
curl -X POST "https://api.tidio.co/v1/contacts" \
  -H "X-Tidio-Openapi-Client-Id: YOUR_CLIENT_ID" \
  -H "X-Tidio-Openapi-Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "customer@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": "+1234567890",
    "distinct_id": "zammad-user-42",
    "properties": {
      "company_name": "Acme Corp",
      "zammad_org_id": "42"
    }
  }'
```

**Expected success response** (`201 Created`):
```json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "email": "customer@example.com",
  "first_name": "Jane",
  "last_name": "Doe"
}
```

**Common error responses:**
- `422 Unprocessable Entity` — validation failure. Example: `distinct_id` exceeds 55 characters, or a required property has an invalid type.
- `201 Created` with a *new* UUID — this is the duplicate trap. If you POST a contact with an email that already exists, Tidio creates a second contact. There is no error. Always look up by email first using `GET /contacts?email=customer@example.com`, and use `PATCH` if the contact exists.

Store the returned Tidio `contact_id` mapped to the original Zammad `user_id` in your staging database. You need this mapping for every subsequent step.

### Step 4: Create Tickets and Replay Articles

For each Zammad ticket, create a ticket in Tidio linked to the correct contact, then replay each article as a reply:

```bash
# Create ticket
curl -X POST "https://api.tidio.co/v1/tickets" \
  -H "X-Tidio-Openapi-Client-Id: YOUR_CLIENT_ID" \
  -H "X-Tidio-Openapi-Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Original Zammad Ticket Title",
    "contact_id": "TIDIO_CONTACT_UUID",
    "content": "First article body from Zammad"
  }'

# Replay subsequent articles as replies (in chronological order)
curl -X POST "https://api.tidio.co/v1/tickets/TICKET_ID/reply" \
  -H "X-Tidio-Openapi-Client-Id: YOUR_CLIENT_ID" \
  -H "X-Tidio-Openapi-Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "[Original Date: 2023-04-15T14:30:00Z]\n\nFollow-up article body",
    "message_type": "public"
  }'

# Patch ticket status after all replies are loaded
curl -X PATCH "https://api.tidio.co/v1/tickets/TICKET_ID" \
  -H "X-Tidio-Openapi-Client-Id: YOUR_CLIENT_ID" \
  -H "X-Tidio-Openapi-Client-Secret: YOUR_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "solved"
  }'
```

> [!NOTE]
> Tidio's reply endpoint supports both `public` and `internal` message types. Map Zammad articles with `internal: true` to `internal` and customer-facing articles to `public`. Getting this wrong exposes internal agent discussions to the customer. Validate this mapping in staging before running against production data.

### Step 5: Validate

Run the validation protocol defined in the Validation section below. Compare record counts between source and target. Spot-check tickets for correct threading, contact assignment, and custom property values. Only then schedule delta sync or final cutover.

## Automation Script Outline (Python)

```python
import requests
import time
import logging

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

# --- Configuration ---
ZAMMAD_URL = "https://your-zammad.com"
ZAMMAD_TOKEN = "your_zammad_token"
TIDIO_CLIENT_ID = "your_tidio_client_id"
TIDIO_CLIENT_SECRET = "your_tidio_secret"
TIDIO_API = "https://api.tidio.co/v1"

zammad_headers = {"Authorization": f"Token token={ZAMMAD_TOKEN}"}
tidio_headers = {
    "X-Tidio-Openapi-Client-Id": TIDIO_CLIENT_ID,
    "X-Tidio-Openapi-Client-Secret": TIDIO_CLIENT_SECRET,
    "Content-Type": "application/json"
}

# Dead-letter queue for records that fail after max retries
dead_letter = []

def tidio_post(endpoint, payload, max_retries=5):
    """POST to Tidio with exponential backoff for rate limits."""
    for attempt in range(max_retries):
        resp = requests.post(f"{TIDIO_API}{endpoint}",
                             headers=tidio_headers, json=payload)
        trace_id = resp.headers.get("x-trace-id", "unknown")
        logger.info(f"POST {endpoint} → {resp.status_code} trace={trace_id}")

        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", 2 ** attempt * 5))
            logger.warning(f"Rate limited. Retrying in {retry_after}s...")
            time.sleep(retry_after)
            continue
        if resp.status_code >= 500:
            wait = 2 ** attempt * 5
            logger.warning(f"Server error {resp.status_code}. Retrying in {wait}s...")
            time.sleep(wait)
            continue
        if resp.status_code == 422:
            logger.error(f"Validation error: {resp.text}")
            return None  # Caller handles dead-letter
        resp.raise_for_status()
        return resp.json()

    logger.error(f"Max retries exceeded for {endpoint}")
    return None

def extract_zammad_users():
    """Paginate through all Zammad users."""
    users, page = [], 1
    while True:
        resp = requests.get(
            f"{ZAMMAD_URL}/api/v1/users",
            headers=zammad_headers,
            params={"page": page, "per_page": 100, "expand": "true"}
        )
        batch = resp.json()
        if not batch:
            break
        users.extend(batch)
        page += 1
        logger.info(f"Extracted {len(users)} users so far...")
    return users

def lookup_contact_by_email(email):
    """Check if a contact already exists in Tidio."""
    resp = requests.get(
        f"{TIDIO_API}/contacts",
        headers=tidio_headers,
        params={"email": email}
    )
    if resp.status_code == 200:
        data = resp.json()
        if data.get("data") and len(data["data"]) > 0:
            return data["data"][0]["id"]
    return None

def migrate_contacts(zammad_users):
    """Create Tidio contacts from Zammad users. Returns ID map."""
    id_map = {}
    for user in zammad_users:
        if user.get("role_ids") and 3 in user["role_ids"]:  # Customer role
            email = user.get("email")
            # Deduplicate: check if contact already exists
            existing_id = lookup_contact_by_email(email) if email else None
            if existing_id:
                id_map[user["id"]] = existing_id
                logger.info(f"Contact exists for {email}, skipping create")
                continue

            distinct_id = f"zammad-user-{user['id']}"
            if len(distinct_id) > 55:
                distinct_id = distinct_id[:55]
                logger.warning(f"Truncated distinct_id for user {user['id']}")

            payload = {
                "email": email,
                "first_name": user.get("firstname", ""),
                "last_name": user.get("lastname", ""),
                "phone": user.get("phone", ""),
                "distinct_id": distinct_id,
            }
            result = tidio_post("/contacts", payload)
            if result:
                id_map[user["id"]] = result["id"]
            else:
                dead_letter.append({"type": "contact", "source": user})
    return id_map

def migrate_tickets(id_map):
    """Create tickets and replay articles."""
    page = 1
    while True:
        resp = requests.get(
            f"{ZAMMAD_URL}/api/v1/tickets",
            headers=zammad_headers,
            params={"page": page, "per_page": 100, "expand": "true"}
        )
        tickets = resp.json()
        if not tickets:
            break
        for ticket in tickets:
            # Skip merged tickets (handle separately if needed)
            if ticket.get("state") == "merged":
                logger.info(f"Skipping merged ticket {ticket['id']}")
                continue

            tidio_contact_id = id_map.get(ticket["customer_id"])
            if not tidio_contact_id:
                dead_letter.append({"type": "ticket", "source": ticket,
                    "error": "No contact mapping"})
                continue

            # Get articles first to use first article as ticket content
            articles_resp = requests.get(
                f"{ZAMMAD_URL}/api/v1/ticket_articles/by_ticket/{ticket['id']}",
                headers=zammad_headers
            )
            articles = articles_resp.json()

            first_content = articles[0].get("body", "") if articles else ""
            ticket_payload = {
                "contact_id": tidio_contact_id,
                "subject": ticket["title"],
                "content": first_content,
            }
            result = tidio_post("/tickets", ticket_payload)
            if not result:
                dead_letter.append({"type": "ticket", "source": ticket})
                continue
            tidio_ticket_id = result["id"]

            # Replay remaining articles as replies (skip first)
            for article in articles[1:]:
                msg_type = "internal" if article.get("internal") else "public"
                original_ts = article.get("created_at", "unknown")
                reply_payload = {
                    "content": f"[Original Date: {original_ts}]\n\n{article.get('body', '')}",
                    "message_type": msg_type,
                }
                tidio_post(f"/tickets/{tidio_ticket_id}/reply", reply_payload)
        page += 1

    if dead_letter:
        logger.error(f"{len(dead_letter)} records in dead letter queue")
        # Write dead_letter to file for investigation
        import json
        with open("dead_letter.json", "w") as f:
            json.dump(dead_letter, f, indent=2)
```

Log every request with source ID, target ID, HTTP status, and Tidio `x-trace-id` header. Retry 429 and 5xx with exponential backoff. Dead-letter any record that fails more than the configured threshold so you can investigate and replay it later.

## Edge Cases and Challenges

- **Organization flattening.** Zammad organizations with dozens of members all share a single org record. In Tidio, you must duplicate the org name as a Contact Property on every member. If the org has custom attributes, each one becomes a separate property on each contact. If a Zammad user belongs to multiple organizations (via the `organization_ids` array), you must decide which one becomes the primary `company_name`, since Tidio's flat schema cannot handle one-to-many company relationships. Consider storing additional org IDs in a semicolon-delimited text property (e.g., `secondary_orgs: "Org-A;Org-B"`).

- **Attachment handling.** Zammad stores attachments within ticket articles and exposes them via the attachment download endpoint (`/api/v1/ticket_attachment/TICKET_ID/ARTICLE_ID/ATTACHMENT_ID`). Tidio's documented ticket create/reply endpoints do not show attachment upload parameters. Download each attachment from Zammad, host it externally (S3 with pre-signed URLs or GCS with signed URLs, set to expire in 5+ years), and embed the link in the reply body. For a migration of 10,000 tickets, expect attachment storage in the range of 5–50 GB depending on your industry. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/ticket/articles.html))

- **Duplicate contacts.** Zammad allows multiple users with different roles sharing the same email. Tidio's `POST /contacts` always creates a new record — there is no error, no deduplication, no warning. The response is `201 Created` with a brand new UUID. Merge or choose a canonical record before migration, and always call `GET /contacts?email=...` before every create call. ([developers.tidio.com](https://developers.tidio.com/reference/post_contacts))

- **Timestamps.** Tidio's API does not allow you to set `created_at` on tickets or replies. Historical timestamps from Zammad cannot be preserved natively. Two workarounds: (1) embed the original timestamp in the message body — prepend `[Original Date: 2023-04-15T14:30:00Z]` to the imported message content; (2) store the original timestamp in a ticket custom field named `original_created_at`. Neither is ideal, but the custom field approach preserves sort/filter capability. ([developers.tidio.com](https://developers.tidio.com/reference/post_tickets-as-contact))

- **Internal notes vs. public replies.** Zammad's ticket articles have a boolean `internal` flag. Map this to Tidio's `message_type` field (`internal` vs `public`). If you get this wrong, sensitive internal discussions become visible to the customer. Validate by querying migrated tickets and checking `message_type` for every reply against the source `internal` flag.

- **Merged tickets.** Zammad tickets with state `merged` have a link to their parent ticket. Tidio has no merge concept. Three options: (1) skip merged tickets entirely — simplest, but you lose that conversation history; (2) migrate merged tickets as standalone tickets with a custom field pointing to the parent ticket's Tidio ID; (3) append the merged ticket's articles to the parent ticket's reply chain, prefixed with `[Merged from Ticket #123]`. Option 3 preserves the most context but is the most complex to implement.

- **Rate limiting math.** At 60–120 requests per minute, a migration of 10,000 tickets with 5 articles each requires approximately 60,000 API calls (10K contact creates + 10K ticket creates + 40K replies + status patches + tag operations). At 120 rpm with zero errors, that's roughly 8.3 hours of continuous API calls. With a realistic 5–10% retry rate, budget 10–12 hours. Build in exponential backoff, checkpoint logic (persist progress to database, not just memory), and resume capability. ([developers.tidio.com](https://developers.tidio.com/docs/openapi-rate-limiting))

- **Custom schema constraints.** Tidio's contact property types are limited to five (text, email, number, phone, url). Complex Zammad attributes (select, tree select, boolean, datetime) require simplification — see the attribute compatibility table in the Pre-Migration Planning section. Existing contact properties in Tidio cannot be deleted, only hidden. Creating a property with the wrong name or type is permanent clutter. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/5444927889052-Contact-Properties))

- **Zammad Cloud vs. Self-Hosted.** Zammad Cloud instances do not expose direct database access — all extraction must go through the REST API. Self-hosted instances (both Docker and package-based) can extract directly from PostgreSQL, bypassing API pagination limits. If you're on Zammad Cloud with more than 50,000 tickets, contact Zammad support about bulk export options before relying solely on paginated API calls.

## Limitations You Cannot Work Around

| Constraint | Impact |
|---|---|
| No Organization object in Tidio | Org hierarchy is lost; flattened to contact-level properties |
| Operators are read-only via API | All agents must be manually created in Tidio admin (~2 min each) |
| No Knowledge Base import | KB articles must be recreated manually or archived |
| No bulk ticket import endpoint | Tickets must be created one at a time via API |
| Rate limits: 60–120 rpm | 10K tickets ≈ 8–12 hours of API calls; requires throttling and retry logic |
| No timestamp override on ticket creation | Original Zammad ticket dates cannot be preserved natively |
| Custom property types limited to 5 | Complex Zammad attributes require simplification |
| Contact properties cannot be deleted | Only hidden — one chance to get the schema right |
| `POST /contacts` creates duplicates silently | Returns `201 Created` with new UUID; no deduplication warning |
| Zammad report download capped at 6,000 rows | Not a viable large-scale extraction method ([admin-docs.zammad.org](https://admin-docs.zammad.org/en/6.3/manage/report-profiles.html)) |
| Batch contact create is all-or-nothing | One validation failure in a batch of 100 rejects the entire batch |

## Validation and Testing Protocol

Do not assume a `200 OK` or `201 Created` response means the data is correctly formatted or semantically correct.

### Pass/Fail Criteria

| Check | Method | Pass Threshold | Failure Action |
|---|---|---|---|
| **Total contact count** | `COUNT` in Zammad vs. Tidio | 100% match (±0) | Investigate missing records in dead-letter queue |
| **Total ticket count** | `COUNT` in Zammad (excluding merged/spam) vs. Tidio | 100% match (±0) | Replay failed tickets from checkpoint |
| **Total reply count** | Sum of Zammad articles vs. Tidio replies per ticket | ≥99% match | Identify tickets with missing replies; re-extract articles |
| **Email match** | Sample 50 contacts, compare email field | 100% match | Data corruption — halt and investigate transform logic |
| **Custom property values** | Sample 50 contacts, compare all properties | ≥98% match | Review type conversion logic for failing property |
| **Thread order** | Sample 20 tickets with 5+ articles, verify chronological order | 100% correct order | Fix article sorting in extraction step |
| **Internal/public mapping** | Sample 20 tickets, verify `message_type` against source `internal` flag | 100% match | **Critical** — halt migration; internal notes exposed to customers |
| **Contact-ticket association** | Sample 20 tickets, verify correct contact assignment | 100% match | Review ID mapping table for corrupted entries |
| **Attachment links** | Sample 10 tickets with attachments, verify links resolve | ≥95% links working | Check S3/GCS permissions and URL expiration |
| **Tag assignment** | Sample 20 tickets with tags, verify all tags present | ≥95% match | Review tag name → ID resolution |

### Validation Process

1. **Automated count comparison.** Run counts on both sides immediately after load completes. Flag any discrepancy above zero for contacts and tickets.
2. **Field-level spot checks.** Randomly sample 50 contacts and 20 tickets. Compare every field programmatically — do not rely on visual inspection alone.
3. **Thread integrity audit.** For 20 randomly selected tickets with 5+ articles, verify that every Zammad article appears as a reply in the correct chronological order in Tidio.
4. **Edge case verification.** Specifically check: tickets from users who belonged to multiple organizations; tickets with 10+ articles; tickets with attachments; merged tickets (if migrated); tickets with internal notes.
5. **UAT process.** Have 3–5 senior support agents log into Tidio and manually review 10 complex historical tickets each. If attachments or internal notes are missing, halt the migration and fix the transformation logic before proceeding.
6. **Rollback plan.** If Tidio was empty before migration, rollback means deleting all migrated contacts and tickets via the API (use your ID mapping table to identify migrated records). If Tidio had existing data, tag all migrated records with a `migration_batch_id` property so you can isolate and remove them if needed.

For a detailed post-load QA checklist, see the [post-migration QA guide](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

## Post-Migration Tasks

Once the data is moved, the operational transition begins.

- **Rebuild automations.** Zammad Triggers, Schedulers, and Macros do not migrate. Document each Zammad trigger's conditions and actions, then rebuild equivalent logic using Tidio's Flows (for chatbot-style automations) and routing rules (for ticket assignment). Pay special attention to SLA timers — Tidio's SLA configuration is under Settings > Helpdesk and uses different escalation logic than Zammad's.
- **Rebuild canned responses.** Zammad text modules have no migration path. Export them via `GET /api/v1/text_modules` and recreate them as Tidio canned responses. If you have more than 50 text modules, consider scripting this through Tidio's admin interface.
- **Knowledge Base.** Tidio does not have a native Knowledge Base equivalent to Zammad's structured KB (categories → articles → translations). If you used Zammad's KB heavily, migrate those articles to a dedicated tool (Document360, Notion, or GitBook) and link them within Tidio chat responses. Alternatively, export Zammad KB articles via `GET /api/v1/knowledge_base/` endpoints and archive them as HTML/Markdown files.
- **Train agents.** Tidio's interface is significantly different from Zammad's. Key differences agents will notice: (1) no Organization tab — company info is in Contact Properties; (2) conversations are chat-centric, not email-centric; (3) ticket search works differently; (4) internal notes use a different UI flow. Budget 2–4 hours of hands-on training per agent.
- **Monitor for gaps.** Run the automated count comparison daily for the first two weeks post-migration. Watch for missing replies (articles that failed silently), broken contact associations, or missing custom property values. Set up a Slack/Teams alert if the dead-letter queue gets new entries during delta sync.
- **Decommission Zammad.** Keep Zammad in read-only mode for 90 days after cutover. This gives your team a fallback for looking up historical context that may not have migrated cleanly (original timestamps, merged ticket chains, KB articles). After 90 days, take a final database backup and decommission.

## Best Practices

- **Never migrate directly to production first.** Always run a subset of data (e.g., 500 contacts and 1,000 tickets) into a Tidio test environment. Tidio does not offer a formal sandbox — use a separate Tidio project on the same plan tier for testing.
- **Back up Zammad.** For self-hosted: take a full PostgreSQL database dump (`pg_dump`) and file system backup before starting. For Zammad Cloud: export all data via the API and store locally.
- **Use a delta sync.** Migrate 90% of your historical data a week before go-live. On cutover weekend, only migrate tickets created or updated since the initial load (filter by `updated_at` in Zammad). This keeps the cutover window to 1–2 hours for most datasets.
- **Validate incrementally,** not only at the end. Run count checks after each batch of 1,000 records.
- **Keep a loss register.** Document every field or behavior that cannot be preserved natively in a shared spreadsheet. Share this with stakeholders before go-live so there are no surprises. Example entries: "Original ticket timestamps not preserved — stored as [Original Date] prefix in message body," "Organization hierarchy flattened to contact-level property."
- **Automate repeatable steps.** Do not hand-edit production CSVs after sign-off. Every transformation should be in version-controlled code.
- **Checkpoint your progress.** Persist the last successfully processed Zammad page/ID to your staging database, not just memory. If your script crashes at hour 6 of a 10-hour run, you need to resume from where you stopped, not restart.

## Sample Data Mapping Table

| Zammad Field | Tidio Field | Transform |
|---|---|---|
| `user.id` | `contact.distinct_id` | `zammad-user-<id>`, truncate to 55 chars ([developers.tidio.com](https://developers.tidio.com/reference/post_contacts)) |
| `firstname` | `first_name` | Direct |
| `lastname` | `last_name` | Direct |
| `email` | `email` | Direct |
| `organization.name` | Contact property `company_name` | Flatten org context ([docs.zammad.org](https://docs.zammad.org/en/latest/api/organization.html)) |
| `organization.domain` | Contact property `company_domain` | Flatten |
| `organization.note` | Contact property `company_notes` | Truncate if needed |
| `ticket.id` | Ticket custom field `source_ticket_id` | Preserve source lookup ([developers.tidio.com](https://developers.tidio.com/reference/get_tickets-custom-fields)) |
| `ticket.title` | `subject` | Direct |
| `ticket.state` | `status` | `new`/`open` → `open`, `pending *` → `pending`, `closed` → `solved` ([developers.tidio.com](https://developers.tidio.com/reference/patch_tickets-ticketid)) |
| `ticket.priority` | `priority` | `1 low` → `low`, `2 normal` → `normal`, `3 high` → `urgent` |
| `ticket.created_at` | Ticket custom field `original_created_at` | ISO 8601 string; cannot set native `created_at` |
| `article.body` | `reply.content` | Strip `<script>` tags; sanitize unsafe HTML |
| `article.internal` | `reply.message_type` | `true → internal`, `false → public` |
| `article.created_at` | Prepended to `reply.content` | `[Original Date: <ISO8601>]` prefix |
| `tags []` | `tag_ids []` | Resolve by name via `GET /tickets/tags` first ([developers.tidio.com](https://developers.tidio.com/reference/get_tickets-tags)) |
| Attachment metadata | Reply body link | Host on S3/GCS with long-lived signed URL |

## When to Use a Managed Migration Service

Building a Zammad-to-Tidio pipeline in-house is straightforward for small datasets (under 1,000 tickets). Beyond that, the combination of Tidio's aggressive rate limits, the lack of bulk import endpoints, the organization-flattening complexity, and the contact deduplication gotchas make this migration deceptively time-consuming.

Common hidden costs of DIY migration:

- **Duplicate contacts** from `POST /contacts` without lookup-first logic — Tidio silently creates duplicates, and cleaning them up later means identifying and merging contact records while preserving ticket associations.
- **Rate limit management** — building, testing, and tuning backoff logic against Tidio's per-project rate limits. A naive implementation without checkpoint/resume will cost you entire re-runs when a script crashes at hour 7.
- **Attachment hosting** — provisioning external storage (S3/GCS), generating long-lived signed URLs, and embedding them in message bodies. For 10,000+ tickets, expect to manage 5–50 GB of attachment data.
- **Broken relationships** when organization context is flattened without a consistent key. Contacts from the same Zammad organization may end up with inconsistent `company_name` values if org data changed during extraction.
- **Validation scripting** — writing and running comparison scripts across both APIs. Automated validation alone typically takes 20–40 hours of development time for a comprehensive suite.
- **Edge case debugging** — handling the 5–10% of records that fail due to data format mismatches, encoding issues, or unexpected null values in Zammad's data.

Help Desk Migration's public platform list includes both Zammad and Tidio. Verify their coverage of custom fields, organization flattening, and attachment handling before committing. ([help-desk-migration.com](https://help-desk-migration.com/supported-platforms/))

At [ClonePartner](https://clonepartner.com/blog/), we handle helpdesk migrations across platforms with similar architectural gaps — including [Zammad-to-Zendesk](https://clonepartner.com/blog/blog/zammad-to-zendesk-migration-the-technical-guide/) and other [Tidio migrations](https://clonepartner.com/blog/blog/freshdesk-to-tidio-migration-guide/). We handle the rate limit choreography, organization flattening, attachment hosting, and validation.

> Migrating from Zammad to Tidio with ticket history, custom fields, or attachment edge cases? Book a 30-minute scoping call.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can I migrate Zammad ticket history to Tidio?

Yes, but only via the API. You must extract ticket articles from Zammad and replay them sequentially as replies in Tidio using the POST /tickets/{ticketId}/reply endpoint. There is no bulk ticket import option. Original timestamps cannot be preserved natively — embed them in message content or a custom field.

### Does Tidio support organizations like Zammad?

No. Tidio has no dedicated Organization object. You must flatten organization data (name, domain, notes) into Contact Properties on individual contact records. The organizational hierarchy from Zammad is lost, and updating a company name on one contact will not propagate to others.

### How do I avoid duplicate contacts in Tidio during migration?

Tidio's POST /contacts endpoint always creates a new contact and does not overwrite matching email or distinct_id. Look up existing contacts first and use PATCH for updates, or use the /contacts/batch endpoint for controlled create/update operations.

### Can I migrate Zammad agents to Tidio via API?

No. The Tidio API is read-only for operators. All agents and admins must be created manually through the Tidio admin panel before starting the migration.

### What Zammad data cannot be migrated to Tidio?

Knowledge Base articles, SLA policies, triggers, macros, text modules, and agent accounts have no API import pathway in Tidio. Attachments have no native upload path in ticket replies. These must be manually recreated, archived externally, or hosted as external links.
