---
title: "Ada to Zammad Migration: The Technical Guide"
slug: ada-to-zammad-migration-the-technical-guide
date: 2026-08-24
author: Nachi
categories: [Migration Guide, Help Desk]
excerpt: "Technical guide for migrating Ada conversational data to Zammad tickets via API. Covers data model mapping, extraction constraints, message transforms, and edge cases."
tldr: "Ada to Zammad migration requires API-to-API scripting with no native path. Extract via Ada's Data Export API (12-month limit, 60-day windows) and load through Zammad's REST API, transforming 15+ message types into ticket articles."
canonical: https://clonepartner.com/blog/ada-to-zammad-migration-the-technical-guide/
---

# Ada to Zammad Migration: The Technical Guide


# Ada to Zammad Migration: The Technical Guide

Migrating from Ada to Zammad means translating data from an AI-powered conversational platform into a traditional helpdesk ticketing system. There is no native import path, no vendor connector, and no built-in migration wizard between these two platforms. Ada stores **conversations** and **messages** — chatbot interactions with end users across chat, email, voice, and social channels. Zammad stores **tickets** with **articles** — structured support records attached to groups, customers, and agents.

To preserve conversation history in Zammad with correct timestamps, sender attribution, and internal notes, you must extract via Ada's Data Export API and load through Zammad's REST API with custom transformation scripts bridging the gap.

This guide covers the full architecture: data model differences, object mapping, API constraints on both sides, extraction strategies, transform logic, edge cases, rollback procedures, and the failure modes that trip up teams mid-migration.

**Verified against:** Ada Data Export API v2 and Zammad 6.x REST API as of mid-2025. Ada's API versions have changed significantly between v1.0 and v2 — confirm your subscription includes Data Export access before planning.

For background on extracting data from Ada, see [How to Export Data from Ada: API Limits, Methods & Portability](https://clonepartner.com/blog/blog/how-to-export-data-from-ada-api-limits-methods-portability/). For Zammad export considerations (useful if you're running parallel tests or evaluating future portability, such as a [Zammad to Zendesk](https://clonepartner.com/blog/blog/zammad-to-zendesk-migration-the-technical-guide/) or [Zammad to Help Scout](https://clonepartner.com/blog/blog/zammad-to-help-scout-migration-a-technical-guide/) migration), see [How to Export Data from Zammad: Methods, API Limits & Portability](https://clonepartner.com/blog/blog/how-to-export-data-from-zammad-methods-api-limits-portability/).

> [!WARNING]
> Do not attempt to import Ada conversations into Zammad without a staging environment. Zammad's database schema strictly enforces relational integrity between Users, Tickets, and Articles. Pushing malformed payloads will result in orphaned records that are difficult to clean up in production. Confirm that your Ada subscription includes Data Export API access before starting migration planning.

## ETL Pipeline Architecture

The full Ada → Zammad migration follows a four-stage ETL pipeline. Understanding the structure before writing code prevents the most common mid-migration failures.

```
┌─────────────────────────────────────────────────────────────────┐
│  STAGE 1: EXTRACT                                               │
│  Ada Data Export API v2                                         │
│  /api/v2/export/conversations  →  60-day windowed pagination    │
│  /api/v2/export/messages       →  joined by conversation_id     │
│  /v2/end-users/                →  PII lookup (email, name)      │
└───────────────────┬─────────────────────────────────────────────┘
                    │  Raw JSON → local files
┌───────────────────▼─────────────────────────────────────────────┐
│  STAGE 2: STAGE                                                 │
│  PostgreSQL staging database                                    │
│  ada_chatters | ada_conversations | ada_messages                │
│  + zammad_ticket_id column (idempotency key)                    │
└───────────────────┬─────────────────────────────────────────────┘
                    │  Transform per row
┌───────────────────▼─────────────────────────────────────────────┐
│  STAGE 3: TRANSFORM                                             │
│  Conversation → Ticket payload                                  │
│  Message (_type dispatch) → Article payload                     │
│  Chatter → Zammad User (create or match)                        │
│  Ada variables → Custom object attributes                       │
└───────────────────┬─────────────────────────────────────────────┘
                    │  REST API calls
┌───────────────────▼─────────────────────────────────────────────┐
│  STAGE 4: LOAD + VALIDATE                                       │
│  POST /api/v1/tickets (ticket + first article)                  │
│  POST /api/v1/ticket_articles (subsequent messages)             │
│  Count check: staging DB rows == Zammad API count               │
│  Spot-check: timestamp, sender, body content                    │
└─────────────────────────────────────────────────────────────────┘
```

This pipeline is the frame for everything that follows. Each section below maps to one stage.

## Why Teams Migrate from Ada to Zammad

Ada is an agentic customer experience (ACX) platform designed to automate conversations using AI agents across messaging, email, and voice. It excels at deflection and automated resolution — not at traditional ticket management, SLA tracking, or agent-driven workflows.

Zammad is an open-source helpdesk and ticketing platform built for agent-driven support. It offers full ticket lifecycle management, email channel integration, SLA policies, knowledge base, and a comprehensive REST API. Zammad can be self-hosted (GPL license) or used via Zammad's managed SaaS offering.

Common reasons teams make this move:

- **Shifting from automation-first to agent-first support.** Ada handles the "zero-touch" layer well, but when conversation volume requiring human agents grows, teams need a proper ticketing system with ownership, escalation, and queue management.
- **Consolidation of conversation archives.** Ada was used as a front-line bot with handoffs to another system. The team wants all historical conversations in Zammad as tickets for unified search and reporting.
- **Open-source preference and data ownership.** Zammad is open-source and self-hostable, eliminating per-agent licensing costs and giving teams full database access.
- **Need for structured support workflows.** Zammad provides groups, SLAs, triggers, macros, and overviews — operational tooling that Ada's conversational model doesn't provide.
- **Ada's 12-month data retention limit.** Teams that need to preserve conversation history beyond Ada's export window must migrate to a system they control before that window closes.

## Ada vs. Zammad: Data Model Comparison

Understanding the structural gap between these platforms is the single most important step before writing any migration code. The core challenge: **Ada's data is conversation-shaped** (flat message streams with typed metadata), while **Zammad's data is ticket-shaped** (structured records with lifecycle states, ownership, and internal visibility).

| Concept | Ada | Zammad |
|---|---|---|
| Core record | Conversation (`_id`) | Ticket (`id`) |
| Individual messages | Messages (linked by `conversation_id`) | Articles (linked by `ticket_id`) |
| Customer identity | Chatter (`chatter_id`) / End User (`end_user_id`) | User (Customer role) |
| Agent identity | `agent_id` / `agent_name` arrays | User (Agent role) |
| Bot identity | `sender: "ada"` on messages | Agent user (manually created) |
| Grouping/routing | None (platform-level) | Groups (department-level routing) |
| Status tracking | `is_engaged`, `is_escalated` booleans | States: new, open, closed, pending, pending reminder |
| Priority | None | Priority levels (1 low – 3 high) |
| Message content | `message_data` JSON (typed: text, picture, link, quick_replies, meta, tool_call, etc.) | Article body (HTML or plain text) |
| Attachments | Inline URLs in `message_data` (e.g., `pic_url`) | Base64-encoded files on articles |
| Satisfaction | CSAT object on conversation | No built-in CSAT (custom attributes or tags required) |
| Custom data | `variables` and `metavariables` dictionaries | Custom object attributes (created via API before import) |
| Knowledge base | Knowledge API (sources, articles, tags) | Knowledge Base (categories, answers, translations) |
| Real-time delivery | Webhooks (outbound events) | Webhooks (inbound triggers) |

Every Ada conversation must be transformed into a Zammad ticket with properly attributed articles. Ada messages are grouped by `conversation_id`, a Zammad ticket is created, and messages are appended as articles in chronological order.

**On webhooks and cutover:** Ada supports outbound webhooks for real-time conversation events. If you need to sync ongoing conversations during a live cutover window (rather than doing a point-in-time historical migration), you can use Ada's webhook events to drive incremental loads into Zammad. This guide focuses on batch historical migration; webhook-based sync is a separate architectural pattern requiring a durable queue between Ada and your transform layer.

## Stage 1: Extraction — Getting Data Out of Ada

### Ada Data Export API Constraints

Ada's Data Export API provides two endpoints for bulk extraction:

- `GET /api/v2/export/conversations` — returns conversation-level metadata
- `GET /api/v2/export/messages` — returns individual messages with content

Key constraints to plan around (from Ada Data Export API v2 documentation):

- **Rate limit:** 10 requests per second per endpoint
- **Page size:** Maximum 10,000 records per page
- **Date range:** A single query's end date cannot be more than 60 days after its start date
- **Data retention:** The Data Export API provides access to data from the past 12 months only
- **Ingestion delay:** Newly created conversations take at least two hours to appear in the export API — do not query for data within the previous two hours of your extraction run

The **12-month retention limit** is the hardest constraint. Conversations older than 12 months are not accessible via the Data Export API regardless of your subscription tier. If you have older conversations in a data warehouse (Snowflake, BigQuery, Redshift), use those as your source for pre-12-month data. If you do not, contact Ada support before finalizing your migration plan — there is no API workaround.

### A Note on Ada's End Users API

The Data Export API returns `chatter_id` on conversations and messages, but **does not include customer PII (email, name) in the export payload**. Customer identity lives in a separate endpoint: `GET /v2/end-users/`. You must call this endpoint to retrieve email addresses for Zammad user creation.

Plan three separate extraction phases:
1. Conversations (windowed by date)
2. Messages (same windows, joined to conversations by `conversation_id`)
3. End-user profiles (by chatter ID, for customer email/name lookup)

### Extraction Strategy

Because each query is limited to a 60-day window, iterate over your full date range in chunks. For a 12-month migration, you need at least 7 iterations (7 × 60 days ≥ 365 days):

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

ADA_BASE = "https://<bot-handle>.ada.support"
HEADERS = {"Authorization": "Bearer <your-api-token>"}

def extract_conversations(start_date, end_date):
    """Extract conversations in 60-day windows with retry logic."""
    all_conversations = []
    window_start = start_date
    
    while window_start < end_date:
        window_end = min(window_start + timedelta(days=60), end_date)
        page_url = (
            f"{ADA_BASE}/api/v2/export/conversations"
            f"?created_since={window_start.isoformat()}Z"
            f"&created_to={window_end.isoformat()}Z"
            f"&page_size=10000"
        )
        
        while page_url:
            resp = fetch_with_backoff(page_url, HEADERS)
            data = resp.json()
            all_conversations.extend(data["data"])
            page_url = data.get("meta", {}).get("next_page_uri")
        
        window_start = window_end
    
    return all_conversations

def fetch_with_backoff(url, headers, max_retries=5):
    """Exponential backoff for 429 and 5xx responses."""
    for attempt in range(max_retries):
        resp = requests.get(url, headers=headers)
        if resp.status_code == 429:
            wait = 2 ** attempt
            print(f"Rate limited. Waiting {wait}s before retry {attempt + 1}.")
            time.sleep(wait)
            continue
        if resp.status_code >= 500:
            wait = 2 ** attempt
            print(f"Server error {resp.status_code}. Waiting {wait}s.")
            time.sleep(wait)
            continue
        resp.raise_for_status()
        return resp
    raise RuntimeError(f"Failed to fetch {url} after {max_retries} attempts.")
```

A 429 response from Ada's Data Export API has this structure:
```json
{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Please slow down.",
  "retry_after": 1
}
```

Respect the `retry_after` value when present. Default to exponential backoff when it is absent.

> [!TIP]
> Dump raw Ada API responses to JSON files before any transformation. This gives you a replayable source-of-truth and avoids repeat API calls during development iterations. Organize by window: `conversations_2024-01-01_2024-03-01.json`, `messages_2024-01-01_2024-03-01.json`.

### Stage 2: Staging the Extracted Data

Load extracted records into a local PostgreSQL database for transformation rather than processing everything in memory. This enables idempotent re-runs after failures and allows validation queries before and after loading.

Minimum staging schema:

```sql
CREATE TABLE ada_chatters (
    chatter_id TEXT PRIMARY KEY,
    end_user_id TEXT,
    email TEXT,
    name TEXT,
    zammad_user_id INTEGER  -- populated after Zammad user creation
);

CREATE TABLE ada_conversations (
    id TEXT PRIMARY KEY,
    chatter_id TEXT REFERENCES ada_chatters(chatter_id),
    date_created TIMESTAMPTZ,
    date_updated TIMESTAMPTZ,
    is_engaged BOOLEAN,
    is_escalated BOOLEAN,
    generated_topic_v2_title TEXT,
    inquiry_summary TEXT,
    platform TEXT,
    language TEXT,
    csat_score INTEGER,
    variables JSONB,
    metavariables JSONB,
    raw JSONB,
    zammad_ticket_id INTEGER  -- idempotency key: populated after Zammad ticket creation
);

CREATE TABLE ada_messages (
    id TEXT PRIMARY KEY,
    conversation_id TEXT REFERENCES ada_conversations(id),
    date_created TIMESTAMPTZ,
    sender TEXT,
    message_data JSONB,
    message_type TEXT GENERATED ALWAYS AS (message_data->>'_type') STORED,
    zammad_article_id INTEGER  -- populated after Zammad article creation
);

CREATE INDEX ON ada_messages(conversation_id, date_created);
CREATE INDEX ON ada_conversations(zammad_ticket_id) WHERE zammad_ticket_id IS NULL;
```

The `zammad_ticket_id` column on `ada_conversations` and `zammad_article_id` on `ada_messages` are your idempotency keys. Before every API call to Zammad, check if these are populated. If they are, skip the creation. This makes the migration script safely re-runnable after any failure.

**Validation query before migration:**
```sql
-- Count conversations by status to understand your data distribution
SELECT 
    is_engaged,
    is_escalated,
    platform,
    COUNT(*) as conversation_count,
    COUNT(DISTINCT chatter_id) as unique_chatters
FROM ada_conversations
GROUP BY is_engaged, is_escalated, platform
ORDER BY conversation_count DESC;
```

## Object Mapping: Ada → Zammad

### Conversations → Tickets

Each Ada conversation maps to one Zammad ticket. Field mapping decisions:

| Ada Field | Zammad Field | Transform Notes |
|---|---|---|
| `_id` | Tag `ada-id:{value}` + custom attribute | Preserve for audit trail; enables reverse lookup |
| `date_created` | `created_at` | Requires admin API token; verify timestamp override works in test batch |
| `date_updated` | `updated_at` | Set via API payload |
| `chatter_id` | `customer_id` | Create or match Zammad user first; get `zammad_user_id` from staging DB |
| `agent_name [0]` | `owner_id` | Map to Zammad agent user; default to system user if unmatched |
| `is_escalated` + `is_engaged` | `state` | See state mapping logic below |
| `generated_topic_v2_title` | `title` | Fall back to `inquiry_summary` → first message text → "Ada Conversation {_id}" |
| `platform` | Tag | e.g., `ada-chat`, `ada-email`, `ada-sms` |
| `language` | Tag | e.g., `lang:fr` |
| `variables` | Custom object attributes | Requires pre-creating attributes in Zammad before import |
| `csat.score` | Custom attribute `ada_csat_score` | See CSAT section below |

**State mapping logic:**

```python
def map_ada_to_zammad_state(conversation):
    """
    Map Ada conversation flags to Zammad ticket states.
    
    Ada booleans:
      is_engaged: True if the chatter sent at least one message
      is_escalated: True if the conversation was handed off to a human agent
    
    For historical migrations, all conversations are past events.
    Virtually all tickets import as 'closed'.
    """
    if not conversation.get("is_engaged", False):
        # Chatter never sent a message — bot greeted, user left
        return "closed"
    if conversation.get("is_escalated", False):
        # Handed off to human agent — treat as resolved in Zammad
        return "closed"
    # Bot-only conversations that were engaged
    return "closed"  # All historical data imports as closed
```

For historical migrations, all tickets import as `closed`. This is correct behavior — you are archiving completed conversations, not creating active work items.

### Messages → Articles

Each Ada message becomes one Zammad article on the parent ticket. This is the most complex transformation because Ada's `message_data` is a typed JSON object with 15+ distinct `_type` values, each with a different schema.

**Ada `_type` → Zammad article handling:**

| Ada `_type` | Visible/Internal | Transform Logic |
|---|---|---|
| `text` | Visible | Use `message_data.body` directly as article body |
| `picture` | Visible | Download `pic_url`, base64-encode, attach; body: `<img alt="...">` |
| `quick_replies` | Visible | Render button labels as HTML list: `[Bot presented options: 1. X, 2. Y]` |
| `link` | Visible | `<a href="{url}">{alt_text}</a>` |
| `trigger` | Visible | User clicked a button; use `message_data.body` |
| `handoff` | Internal note | "Conversation escalated to agent: {agent_name}" |
| `tool_call` | Internal note | "Tool invoked: {tool_name}, status: {result_status}" |
| `generative_reply` | Internal note | "Generative AI reply generated (confidence: {score})" |
| `meta` | **Skip** | Variable changes, internal tracking events — no user-visible content |
| `greeting` | **Skip** | Bot-initiated greeting — adds noise; import only if you need full audit trails |
| `csat_shown` | **Skip** | CSAT prompt display event |
| `csat` | Internal note | CSAT response recorded; store score in custom attribute |
| `engagement` | **Skip** | Session engagement tracking event |
| `ab_test` | **Skip** | A/B test assignment — no user-visible content |
| `variable_change` | **Skip** | Variable mutation event — no user-visible content |

If you fail to handle these types explicitly, your Zammad tickets will contain raw JSON strings or blank articles. Build the dispatch function before loading any data:

```python
def build_article_body(msg_data, msg_type):
    """
    Returns (body_html, is_internal) or (None, None) for skipped types.
    
    None return means: do not create an article for this message.
    """
    
    # Skip types — do not create articles
    SKIP_TYPES = {
        "meta", "greeting", "csat_shown", "engagement",
        "ab_test", "variable_change", "trigger_campaign",
        "proactive_conversation_opened"
    }
    if msg_type in SKIP_TYPES:
        return None, None
    
    # Visible article types
    if msg_type == "text":
        body = msg_data.get("body", "").strip()
        return (f"<p>{body}</p>", False) if body else (None, None)
    
    elif msg_type == "picture":
        pic_url = msg_data.get("pic_url", "")
        alt = msg_data.get("alt_text", "Image")
        # Caller must handle download + base64 encoding separately
        return (f'<p>[Image: <em>{alt}</em>]</p><p>Source: {pic_url}</p>', False)
    
    elif msg_type == "quick_replies":
        buttons = msg_data.get("buttons", [])
        options = "".join(f"<li>{b.get('label', '')}</li>" for b in buttons)
        return (f"<p>Bot presented options:</p><ul>{options}</ul>", False)
    
    elif msg_type == "link":
        url = msg_data.get("url", "#")
        label = msg_data.get("alt_text", url)
        return (f'<p><a href="{url}">{label}</a></p>', False)
    
    elif msg_type == "trigger":
        body = msg_data.get("body", "[Button clicked]")
        return (f"<p>{body}</p>", False)
    
    # Internal note types
    elif msg_type == "handoff":
        agent = msg_data.get("agent_name", "unknown agent")
        return (f"<p><em>Conversation escalated to: {agent}</em></p>", True)
    
    elif msg_type == "tool_call":
        tool = msg_data.get("tool_name", "unknown tool")
        status = msg_data.get("result_status", "unknown")
        return (f"<p><em>Tool invoked: {tool} — status: {status}</em></p>", True)
    
    elif msg_type == "generative_reply":
        return (f"<p><em>Generative AI response delivered.</em></p>", True)
    
    elif msg_type == "csat":
        score = msg_data.get("score", "N/A")
        return (f"<p><em>CSAT response recorded: {score}/5</em></p>", True)
    
    else:
        # Unknown type — import as internal note with raw JSON for audit
        import json
        return (f"<p><em>[Unknown message type: {msg_type}]</em></p>"
                f"<pre>{json.dumps(msg_data, indent=2)}</pre>", True)
```

The `else` branch handles undocumented or future Ada message types gracefully — they become internal notes rather than silent failures.

**Full message → article transform:**

```python
def transform_message_to_article(message, ticket_id, sender_map, bot_user_id):
    """Transform an Ada message into a Zammad article payload."""
    msg_data = message.get("message_data", {})
    msg_type = msg_data.get("_type", "text")
    
    body, internal = build_article_body(msg_data, msg_type)
    if body is None:
        return None  # Caller should skip this message
    
    ada_sender = message.get("sender", "")
    if ada_sender.lower() == "ada":
        sender = "Agent"
        created_by_id = bot_user_id  # Zammad ID of the "Ada Bot" agent user
    else:
        sender = "Customer"
        created_by_id = sender_map.get(ada_sender)  # Zammad user ID from staging DB
    
    return {
        "ticket_id": ticket_id,
        "body": body,
        "content_type": "text/html",
        "type": "note",
        "sender": sender,
        "internal": internal,
        "created_by_id": created_by_id,
        "created_at": message.get("date_created"),
    }
```

### Chatters → Zammad Users

Ada identifies customers via `chatter_id` (MongoDB ObjectId). Zammad requires a user record for every ticket customer. Because the Data Export API does not include PII, retrieve customer email and name from `GET /v2/end-users/` before this step.

**Creating or matching Zammad users:**

```python
def get_or_create_zammad_user(email, name, zammad_base, headers):
    """
    Use Zammad's guess: shorthand to avoid a separate search call.
    Returns Zammad user ID.
    """
    # First, try to find by email
    search = requests.get(
        f"{zammad_base}/api/v1/users/search?query={email}",
        headers=headers
    )
    results = search.json()
    if results:
        return results[0]["id"]
    
    # Create new user
    payload = {
        "firstname": name.split()[0] if name else "Ada",
        "lastname": " ".join(name.split()[1:]) if name and len(name.split()) > 1 else "Customer",
        "email": email,
        "roles": ["Customer"],
        "active": True
    }
    resp = requests.post(
        f"{zammad_base}/api/v1/users",
        json=payload,
        headers=headers
    )
    resp.raise_for_status()
    return resp.json()["id"]
```

Alternatively, use Zammad's `guess:` shorthand in the ticket payload to create-or-match in a single step: set `"customer_id": "guess:customer@example.com"` in the ticket creation payload. Zammad will find the existing user or create a new one automatically.

**Anonymous chatters (no email available):**

Web chat users frequently have no associated email. Create placeholder users:
- Email: `chatter-{chatter_id}@ada-import.local`
- Name: `Anonymous Ada User`
- Tag all associated tickets with `anonymous-chatter`
- Store the original Ada `chatter_id` in a custom ticket attribute for reverse lookup

**Creating the Ada Bot agent user** (do this before any ticket import):

```json
POST /api/v1/users
{
  "firstname": "Ada",
  "lastname": "Bot",
  "email": "ada-bot@yourdomain.com",
  "roles": ["Agent"],
  "active": true,
  "note": "Automated import user representing Ada AI bot messages"
}
```

Store the returned `id` in your migration configuration. Every message where Ada's `sender` is `"ada"` uses this `id` as `created_by_id`.

### Creating Custom Object Attributes (Before Import)

Ada's `variables` dictionary contains business-specific data (e.g., account ID, plan type, order number). To preserve this in Zammad, pre-create custom object attributes before any ticket import. Missing this step means the data is silently dropped.

```json
POST /api/v1/object_manager_attributes
{
  "object": "Ticket",
  "name": "ada_account_id",
  "display": "Ada Account ID",
  "data_type": "input",
  "data_option": {
    "type": "text",
    "maxlength": 255,
    "null": true
  },
  "active": true
}
```

After creating all required attributes, activate them by running the database migration:

```
POST /api/v1/object_manager_attributes/execute_migrations
{}
```

This endpoint triggers the schema migration in Zammad's database. Without it, the attributes exist in the API but are not yet available on ticket records.

**CSAT as a custom attribute:**

```json
POST /api/v1/object_manager_attributes
{
  "object": "Ticket",
  "name": "ada_csat_score",
  "display": "Ada CSAT Score",
  "data_type": "integer",
  "data_option": {
    "min": 1,
    "max": 5,
    "null": true
  },
  "active": true
}
```

This preserves CSAT data as a queryable field rather than a tag, enabling Zammad reports filtered by satisfaction score.

## Stage 3: Loading Data into Zammad

### Zammad API Basics

Zammad's REST API supports all operations available through the web interface. Authentication for migration scripts: use a dedicated API token with `ticket.agent` and `admin` permissions. Admin permission is required to override `created_at` timestamps. Set it as a header:

```
Authorization: Token token=<your-api-token>
Content-Type: application/json
```

Without the admin token, Zammad ignores `created_at` fields in POST payloads and sets them to the current timestamp. Your entire historical timeline collapses to the import date. **Verify timestamp override behavior with a 5-ticket test batch before running your full migration.**

### Creating Tickets with Articles

Zammad requires the first article be included in the ticket creation payload — you cannot create an empty ticket. Sort Ada messages by `date_created` for each conversation before constructing the payload. The oldest message becomes the initial article:

```json
POST /api/v1/tickets
{
  "title": "Billing inquiry - auto deposit",
  "group": "Support",
  "state": "closed",
  "priority": "2 normal",
  "customer_id": "guess:customer@example.com",
  "created_at": "2023-10-12T14:30:00Z",
  "updated_at": "2023-10-12T14:45:00Z",
  "tags": "ada-chat,lang:en,ada-id:64a1b2c3d4e5f6789",
  "ada_csat_score": 4,
  "article": {
    "body": "<p>I need help resetting my password.</p>",
    "content_type": "text/html",
    "type": "note",
    "sender": "Customer",
    "internal": false,
    "created_at": "2023-10-12T14:30:00Z"
  }
}
```

The article included during ticket creation does not affect any subsequent articles. Add remaining messages from the same Ada conversation via individual `POST /api/v1/ticket_articles` calls:

```json
POST /api/v1/ticket_articles
{
  "ticket_id": 1045,
  "body": "<p>I can help with that. Please click the link below.</p>",
  "type": "note",
  "internal": false,
  "sender": "Agent",
  "created_by_id": 12,
  "content_type": "text/html",
  "created_at": "2023-10-12T14:30:05Z"
}
```

*(`created_by_id: 12` represents the "Ada Bot" agent user created in the previous step.)*

**Loading loop with idempotency:**

```python
def migrate_conversation(conv, messages, staging_db, zammad_client):
    """Migrate one Ada conversation to Zammad with idempotency."""
    
    # Check if already migrated
    if conv["zammad_ticket_id"] is not None:
        print(f"Skipping {conv['id']} — already migrated to ticket {conv['zammad_ticket_id']}")
        return
    
    sorted_messages = sorted(messages, key=lambda m: m["date_created"])
    first_msg = sorted_messages[0]
    
    # Build and POST ticket
    ticket_payload = build_ticket_payload(conv, first_msg)
    ticket = zammad_client.post("/api/v1/tickets", ticket_payload)
    ticket_id = ticket["id"]
    
    # Record ticket ID immediately — before articles
    staging_db.update_conversation_ticket_id(conv["id"], ticket_id)
    
    # Post remaining articles
    for msg in sorted_messages[1:]:
        if msg["zammad_article_id"] is not None:
            continue  # Already posted in a previous run
        article_payload = transform_message_to_article(msg, ticket_id, ...)
        if article_payload is None:
            continue  # Skipped message type
        article = zammad_client.post("/api/v1/ticket_articles", article_payload)
        staging_db.update_message_article_id(msg["id"], article["id"])
```

Recording the `zammad_ticket_id` to the staging database immediately after ticket creation (before articles) is critical. If the script crashes during article posting, the next run will skip ticket creation and only post the missing articles.

### Adding Attachments

For Ada `picture` messages, download the image during Stage 1 (extraction) and store locally. Ada's `pic_url` values may be signed and time-limited — do not attempt to download them during Stage 3.

```json
POST /api/v1/ticket_articles
{
  "ticket_id": 42,
  "body": "<p>Image shared by customer</p>",
  "content_type": "text/html",
  "type": "note",
  "sender": "Customer",
  "internal": false,
  "attachments": [
    {
      "filename": "customer-screenshot.png",
      "data": "iVBORw0KGgoAAAANSUhEUg...",
      "mime-type": "image/png"
    }
  ]
}
```

> [!WARNING]
> Zammad enforces a maximum attachment size (default 50 MB, configurable in Settings → Security → Attachments). Ada's image URLs are typically under 5 MB, but verify before bulk processing. An oversized attachment causes the entire article POST to fail — the article is not created, and no partial state is saved.

### Rate Limits and Throughput

Zammad self-hosted instances have no officially documented per-second write rate limits — throughput is bounded by your server's CPU, RAM, and Elasticsearch indexing capacity. Practical benchmarks from migrations run on standard VM configurations (4 vCPU, 8 GB RAM):

| Configuration | Throughput |
|---|---|
| Self-hosted, no Elasticsearch tuning | ~60–80 ticket creates/minute |
| Self-hosted, Elasticsearch `refresh_interval: 30s` | ~150–200 ticket creates/minute |
| Zammad SaaS (managed) | ~120–180 ticket creates/minute (varies by tier) |

**Elasticsearch indexing is the dominant bottleneck** on self-hosted instances. Reduce index refresh frequency during migration:

```bash
# Increase refresh interval before migration (reduces indexing overhead)
curl -X PUT "localhost:9200/_all/_settings" \
  -H "Content-Type: application/json" \
  -d '{"index": {"refresh_interval": "30s"}}'

# Restore default after migration completes
curl -X PUT "localhost:9200/_all/_settings" \
  -H "Content-Type: application/json" \
  -d '{"index": {"refresh_interval": "1s"}}'
```

Add 100–200ms sleep between API calls to avoid overwhelming Elasticsearch's indexing queue. On Zammad SaaS, if you receive HTTP 429 responses, use the `Retry-After` header value. Do not run highly concurrent multi-threaded loads against Zammad SaaS without coordinating with their support team.

## Stage 4: Validation

Validation is not optional for a migration of this complexity. Run these checks after the full load:

**Count parity check (staging DB vs. Zammad API):**

```sql
-- Check for conversations that didn't get migrated
SELECT COUNT(*) as unmigrated
FROM ada_conversations
WHERE zammad_ticket_id IS NULL;

-- Check for messages that didn't get migrated
SELECT COUNT(*) as unmigrated_articles
FROM ada_messages m
JOIN ada_conversations c ON m.conversation_id = c.id
WHERE m.zammad_article_id IS NULL
  AND c.zammad_ticket_id IS NOT NULL
  AND m.message_type NOT IN ('meta', 'greeting', 'csat_shown', 'engagement', 
                               'ab_test', 'variable_change');
```

**Zammad-side count check:**

```bash
# Get total ticket count
curl -s -H "Authorization: Token token=<token>" \
  "https://your-zammad.com/api/v1/tickets?page=1&per_page=1" \
  | jq '.total_count'
```

Compare this number to your staging DB count of `ada_conversations WHERE zammad_ticket_id IS NOT NULL`.

**Timestamp spot-check:**

Pull 10 random tickets from Zammad and compare `created_at` to the corresponding `date_created` in your staging DB. If they don't match, your admin token may lack the necessary permissions for timestamp override — re-run those tickets with a correctly permissioned token.

**Content spot-check:**

For 5 random conversations, manually compare the article count, sender attribution, and body content in Zammad against the raw JSON in your staging database. Pay particular attention to `quick_replies` messages (verify buttons rendered as HTML lists) and `tool_call` messages (verify they appear as internal notes).

## Rollback Strategy

A migration rollback strategy is essential because Zammad's database enforces relational integrity — partial imports leave orphaned records that are hard to clean up manually.

**Rollback options by scenario:**

| Scenario | Recovery Action |
|---|---|
| Script crashed mid-run, staging DB intact | Re-run migration script — idempotency skips completed records |
| Timestamps all wrong (collapsed to import date) | Delete migrated tickets, fix admin token permissions, re-run |
| Wrong group assignment on all tickets | Use Zammad's bulk ticket update API (`PUT /api/v1/tickets/bulk`) to reassign |
| Corrupt article content on specific message type | Identify affected `zammad_article_id` values in staging DB, delete and re-create those articles |
| Complete failure — need to start over | Delete all imported tickets via batch API call, truncate `zammad_ticket_id` and `zammad_article_id` columns in staging DB, re-run |

**Batch delete for full rollback:**

```python
def delete_all_imported_tickets(staging_db, zammad_client):
    """Delete all Zammad tickets created during this migration."""
    ticket_ids = staging_db.get_all_zammad_ticket_ids()
    for ticket_id in ticket_ids:
        resp = zammad_client.delete(f"/api/v1/tickets/{ticket_id}")
        if resp.status_code == 200:
            staging_db.clear_ticket_id(ticket_id)
        else:
            print(f"Failed to delete ticket {ticket_id}: {resp.status_code}")
```

After deletion, clear the `zammad_ticket_id` and `zammad_article_id` columns in your staging database. The migration script's idempotency logic will then treat all conversations as unmigrated on the next run.

> [!WARNING]
> On Zammad SaaS, bulk deletion of thousands of tickets via the REST API may be rate-limited or restricted. Contact Zammad support before executing a large rollback on a managed instance. On self-hosted Zammad, you can also execute a direct database deletion followed by an Elasticsearch re-index, which is faster for rollbacks exceeding 10,000 tickets.

## Knowledge Base Migration

If you use Ada's Knowledge sources to power AI agent responses, migrate that content to Zammad's Knowledge Base.

Ada's Knowledge API (`GET /v2/knowledge/articles/`) returns articles with content, URLs, and source metadata. Zammad's Knowledge Base organizes content into **categories** and **answers** with **translations** for multi-language support.

| Ada Knowledge | Zammad Knowledge Base |
|---|---|
| Knowledge Source | Category |
| Knowledge Article | Answer (with translation) |
| Article tags | Manual categorization |
| Single language | Multiple translations per answer |

Create categories first, then answers:

```json
POST /api/v1/knowledge_bases/{kb_id}/categories
{
  "translations_attributes": [
    {
      "locale": "en-us",
      "title": "Billing",
      "kb_locale_id": 1
    }
  ]
}
```

Then create answers within each category:

```json
POST /api/v1/knowledge_bases/{kb_id}/answers
{
  "category_id": 5,
  "promoted": false,
  "translations_attributes": [
    {
      "locale": "en-us",
      "title": "How do I reset my password?",
      "content": {
        "body": "<p>To reset your password, click Forgot Password on the login page...</p>"
      },
      "kb_locale_id": 1
    }
  ]
}
```

Zammad's Knowledge Base supports multiple languages per answer via the `translations_attributes` array. If your Ada knowledge content is multilingual, map each language version to a Zammad translation on the same answer — rather than creating duplicate answers per language.

## Edge Cases Reference

### Ada's 12-Month Data Window

A hard cutoff that cannot be extended via the API. Data older than 12 months is not accessible through the Data Export API. Check your data warehouse for older exports, and check with Ada support before assuming the data is permanently inaccessible.

### Message Ordering

Ada messages within a conversation are not guaranteed to arrive in chronological order from the API. Sort by `date_created` before constructing Zammad articles, or your ticket timeline will be scrambled.

### Conversations With Zero Messages

Some Ada conversations have metadata but no messages (bot greeted, user never responded). These still import as Zammad tickets, but you need at least one article. Use the conversation's `inquiry_summary` or a placeholder: `<p><em> [No messages — conversation abandoned before first response]</em></p>`.

### Bot Messages vs. Human Agent Messages After Handoff

Ada conversations may include both AI bot messages and human agent messages (post-escalation). The `sender` field distinguishes them: `"ada"` for the bot, a chatter ID for the customer, and agent IDs for human agents. Map each agent ID to the corresponding Zammad agent user. If no Zammad user exists for an Ada agent, fall back to the Ada Bot user and add the agent's name as an internal note.

### Very Long Conversations

Ada conversations can run hundreds of messages. Zammad has no documented per-ticket article limit, but very large tickets (500+ articles) may cause UI performance issues. For conversations exceeding 200 messages, consider splitting into multiple Zammad tickets with a linking note, or importing only messages after a certain cutoff (e.g., post-escalation only).

## Time and Effort Estimates

These estimates assume one engineer working part-time on migration alongside other responsibilities. Full-time focus reduces elapsed calendar time by approximately 40%.

| Volume | Engineering Effort | Calendar Time | Key Bottleneck |
|---|---|---|---|
| < 5,000 conversations | 2–4 days | 1 week | Script development and validation |
| 5,000–50,000 conversations | 1–2 weeks | 2–3 weeks | Message type handling, extraction runs |
| 50,000–500,000 conversations | 2–4 weeks | 4–6 weeks | Elasticsearch tuning, rate limit management |
| > 500,000 conversations | 4–8 weeks | 8–12 weeks | Infrastructure, parallel processing, staged rollout |

**The message type transformation layer is the dominant engineering effort** — not the API calls themselves. Ada's 15+ distinct `_type` values each have different `message_data` schemas. Building, testing, and validating the dispatch function for each type accounts for roughly 40–50% of total development time. API call time is dominated by Zammad's write throughput, which at 100–200 tickets/minute means a 50,000-ticket migration completes in 4–8 hours of actual API time.

## Migration Checklist

1. **Confirm Ada Data Export API access** — verify your subscription includes it before any planning
2. **Audit data volume** — count conversations and messages per date window: `SELECT date_trunc('month', date_created), COUNT(*) FROM ada_conversations GROUP BY 1 ORDER BY 1`
3. **Check data age** — identify what percentage of conversations fall outside the 12-month window
4. **Map Ada chatters to emails** — extract end-user profiles via the End Users API before extraction runs
5. **Define Zammad group structure** — decide how Ada platform types and escalation states route to Zammad groups
6. **Pre-create custom object attributes** — set up CSAT score, Ada conversation ID, and variable fields in Zammad; run `execute_migrations`
7. **Create bot and placeholder users** — Ada Bot agent user, anonymous chatter placeholder user
8. **Build and test extraction scripts** — handle 60-day windowing, pagination, and exponential backoff
9. **Build transform layer** — implement `build_article_body` dispatch for all `_type` values including an `else` handler
10. **Run a pilot batch** — import 100 conversations and validate timestamps, sender attribution, article order, and content
11. **Verify timestamp override** — confirm `created_at` in Zammad matches Ada's `date_created` in pilot tickets
12. **Tune Elasticsearch** — increase `refresh_interval` before full run on self-hosted instances
13. **Run full migration** — execute during off-hours if Zammad is already live
14. **Run validation queries** — count parity between staging DB and Zammad API; spot-check 5–10 records
15. **Restore Elasticsearch settings** — reset `refresh_interval` to `1s` after migration completes
16. **Document rollback procedure** — confirm batch delete script works before go-live

For broader migration planning, see [Best Practices for Help Desk Data Migration](https://clonepartner.com/blog/blog/best-practices-for-help-desk-data-migration/) and [Zero-Downtime Help Desk Data Migration](https://clonepartner.com/blog/blog/zero-downtime-help-desk-data-migration/). If you are still evaluating destination platforms for your Ada data, see our technical guides for [Ada to Freshservice](https://clonepartner.com/blog/blog/ada-to-freshservice-migration-the-ctos-technical-guide/) and [Ada to Desk365](https://clonepartner.com/blog/blog/ada-to-desk365-migration-a-technical-guide/).

---

Writing a script to map Ada data to Zammad REST endpoints is roughly 20% of a migration project. The remaining 80% is the message type transformation layer, error handling, timestamp validation, attachment processing, idempotency logic, and post-migration verification. Ada's 15+ `_type` values each require explicit handling — miss one, and you get empty articles or import errors at scale. The 12-month data retention limit gives you one extraction window for older conversations. And Zammad's timestamp override behavior depends on API token permissions in ways that are only discoverable through testing.

At ClonePartner, we handle the full pipeline — extraction, transformation, loading, and validation — for Ada to Zammad migrations. If you need this done without pulling engineers off core product work, [book a 30-minute scoping call](https://cal.com/clonepartner/meet?duration=30).

## Frequently asked questions

### Is there a built-in migration tool from Ada to Zammad?

No. Neither Ada nor Zammad provides a native migration path between the two platforms. You must use Ada's Data Export API for extraction and Zammad's REST API for loading, with custom transformation scripts in between.

### How far back can I export data from Ada?

Ada's Data Export API provides access to the past 12 months of conversation data. Each query is limited to a 60-day date range window, and the maximum page size is 10,000 records. Data older than 12 months is not available via the API.

### Can I preserve original timestamps when importing into Zammad?

Zammad's API allows setting created_at on tickets and articles, but this requires admin-level API token permissions. Always test with a small batch first to confirm timestamps are being honored on your specific Zammad version.

### How do I handle anonymous Ada chatters in Zammad?

Zammad requires a valid user for every ticket. Create placeholder users like chatter-{chatter_id}@ada-import.local for anonymous chatters. Tag the tickets accordingly and preserve the original Ada chatter_id for auditing.

### What happens to Ada CSAT data in Zammad?

Zammad has no native CSAT model. You can preserve Ada CSAT scores as ticket tags (e.g., csat:5) or create custom object attributes on Zammad tickets to store satisfaction data.
