Skip to content

Zammad to Kayako Migration: The Technical Guide

Technical guide to migrating from Zammad to Kayako. Covers API extraction, data mapping, internal notes routing, edge cases, and zero-downtime cutover.

Roopi Roopi · · 26 min read
Zammad to Kayako Migration: The Technical Guide
TALK TO AN ENGINEER

Planning a migration?

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

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

Migrating from Zammad to Kayako is an API-to-API data translation project. There is no native migration path, no built-in connector, and no vendor-provided import wizard from Zammad to Kayako. Zammad stores ticket conversations as articles — individual messages attached to a ticket — while Kayako uses a conversation-centric case model with messages nested under cases. If you need full support history in Kayako with timestamps, internal notes, attachments, and user relationships intact, the only reliable path is extracting via Zammad's REST API and loading through Kayako's Cases API.

One nuance worth noting early: Zammad has a built-in migrator for importing from Kayako into Zammad, but there is no reverse path. For Zammad → Kayako, you must build or commission an API-based pipeline.

Expect 1–3 weeks of engineering work for a DIY approach with a mid-size dataset (10K–50K tickets), or 3–5 business days with a managed migration service. The primary variables are ticket volume, attachment count, custom field complexity, and Kayako's API rate limits.

This guide covers the full architecture: object mapping, API constraints on both sides, extraction strategies, transform logic, code examples, and the failure modes that derail engineering teams mid-migration.

Warning

API Versioning Note This guide is based on Zammad 6.x REST API and Kayako API v1 as of mid-2026. Zammad's API can change between major self-hosted versions — verify endpoint behavior against your installed version before scripting. Zammad Cloud instances may have different API rate limits or disabled endpoints compared to self-hosted installations — confirm access to all extraction endpoints before beginning. Several Kayako API reference pages still show a last-updated date of January 2017, so validate behavior in a sandbox before production. (developer.kayako.com)

For related migration planning, see the Help Desk Data Migration Playbook and the Help Desk Data Migration Checklist.

Why Companies Migrate from Zammad to Kayako

The most common technical and operational drivers across Zammad-to-Kayako migration projects:

  • Moving off self-hosted infrastructure. Zammad is open-source and often self-hosted, requiring server maintenance, PostgreSQL/Elasticsearch upgrades, and security patching. Teams running Zammad on-premise who lack dedicated DevOps capacity migrate to Kayako Cloud to eliminate infrastructure overhead. (zammad.com)
  • Conversation-centric data model. Kayako organizes support around customer journeys and conversation timelines rather than ticket lists. Teams that need a unified customer view across email, chat, and social in a single thread find Kayako's case model structurally better suited.
  • Native omnichannel integration. Kayako's live chat, messenger widget, and social media integrations are tightly coupled with its case model — messages from all channels appear as posts within a single case. Zammad supports chat but treats it as a separate channel with a more basic implementation.
  • Visual workflow automation. Kayako's journey builders and SLA management provide drag-and-drop automation configuration suited for multi-tiered support teams without requiring triggers written in Zammad's condition/action syntax.
  • Reduced admin surface area. Zammad's flexibility — custom objects, extensive API surface, nested group hierarchies — becomes overhead for teams under 20 agents. Kayako's narrower configuration model (custom fields on cases, users, and organizations only) reduces the administrative burden.

Core Data Model Differences

Understanding the structural gap between these two platforms is the first step in any migration plan.

Concept Zammad Kayako Migration Impact
Main support record Ticket (number, title, state_id, priority_id) Case (subject, status, priority, type) One-to-one mapping
Messages Articles — each message is a separate object with sender, type, internal flag Posts/Replies — nested under case, with channel and visibility Public vs internal routing matters
Internal notes Article with internal: true Note — separate endpoint (/api/v1/cases/{id}/notes) Must detect internal articles and route differently
Customer User (customer role, linked via customer_id) User (customer role) Match by email
Agent User (agent role, with group permissions) User (agent/admin/collaborator roles) Match by email
Company Organization (organization_id on user) Organization (linked to users) Load before users and cases
Team/routing Group (tickets assigned to groups; supports nesting) Team (cases assigned to teams; flat structure) Flatten nested groups into teams
Tags Tags (per ticket) Tags (per case) Direct map
Custom fields Object Attributes (on tickets, users, orgs, groups) Custom Fields (on cases, users, orgs only) Group-level fields and exotic types need flattening
Knowledge base Knowledge Base (categories → answers, multilingual) Help Center (sections → articles) Separate migration scope — see Knowledge Base section
Automations Triggers, Schedulers, Macros, SLAs Automations, Macros, SLAs Cannot be migrated programmatically — must be rebuilt manually
Warning

Internal Notes Routing Zammad's internal notes are articles on the ticket with internal: true. Kayako separates notes from case messages entirely — they live at a different API endpoint (/api/v1/cases/{id}/notes). Your migration script must detect internal articles and route them to Kayako's notes API, not the replies endpoint. Getting this wrong means internal agent notes become visible to customers. This is the single most common data exposure mistake in helpdesk migrations.

Migration Approaches Compared

1. CSV Export/Import

How it works: Export ticket metadata from Zammad via reports or admin tools, clean in a spreadsheet, and attempt manual import into Kayako.

When to use: You only need users, organizations, or a small subset of recent tickets, and history doesn't matter.

  • Pros: No coding, fast for tiny datasets.
  • Cons: Zammad's reporting download is limited to 6,000 entries and does not export individual articles, internal notes, or attachments. Kayako has no native bulk CSV import for cases — their docs point CSV migrations to third-party services. Historical timestamps are lost. (admin-docs.zammad.org)
  • Scalability: Does not scale beyond a few thousand records.
  • Complexity: Low (but low fidelity).

2. API-Based Custom Migration

How it works: Write a script (Python or Node.js) that extracts all objects from Zammad's REST API, transforms the data to match Kayako's schema, and loads via Kayako's REST API. For larger datasets, stage data in a local PostgreSQL or SQLite database and use Kayako's bulk import endpoint.

When to use: Any migration where you need full control over field mapping, timestamp preservation, and relationship integrity.

  • Pros: Total control, handles custom fields and edge cases, preserves full history and attachments, supports legacy_id for traceability.
  • Cons: High engineering effort (1–3 weeks), must handle pagination, rate limiting, exponential backoff, dead-letter queues for failed records, batch checkpointing, and HTML cleanup. (docs.zammad.org)
  • Scalability: Works at any scale with proper batching. Kayako's bulk cases endpoint accepts up to 200 cases per request. (developer.kayako.com)
  • Complexity: High.

3. Third-Party Migration Tools

How it works: Use an off-the-shelf SaaS migration tool to connect both systems, map fields, validate a sample, then run. Kayako officially documents Help Desk Migration as an import path. (help.kayako.com)

When to use: Standard use cases with mostly default fields, no complex relational mapping.

  • Pros: Faster start (typically configured in hours, not days), less custom code, clearer commercial accountability.
  • Cons: Edge cases like inline images, custom field type transforms, suspended users, and agent-team logic are where tool-based migrations often flatten data or silently drop records. Support is usually tiered by plan.
  • Scalability: Small to mid-market (typically up to ~100K tickets).
  • Complexity: Medium.

4. Middleware Platforms (Zapier, Make)

How it works: Use connectors or HTTP modules to push new Zammad tickets into Kayako on creation or update.

When to use: Ongoing forward sync of new tickets during a transition period. Not suitable for historical migration.

  • Pros: No-code setup, good for incremental coexistence post-migration.
  • Cons: Built for event-driven sync, not bulk historical transfer. Cannot migrate years of ticket history. Per-task pricing gets expensive at volume (Make charges per operation; Zapier charges per task). Make's Kayako app is community-maintained and may lag API changes. (make.com)
  • Scalability: Low for historical data; medium for ongoing sync.
  • Complexity: Medium.

5. Managed Migration Service

How it works: A migration partner handles extraction, transformation, loading, validation, and cutover.

When to use: When you have >10K tickets, complex custom fields, attachments, or cannot afford downtime.

  • Pros: Fastest turnaround (days, not weeks), handles edge cases, validated output, delta-sync for zero-downtime cutover.
  • Cons: External cost (typically scoped per-ticket or fixed-price based on volume and complexity).
  • Scalability: Handles any volume.
  • Complexity: Low (for your team).

Approach Comparison

Factor CSV Export API-Based DIY Third-Party Tool Middleware Managed Service
Historical data ⚠️ Partial ✅ Full ⚠️ Varies by tool ❌ No ✅ Full
Attachments ❌ No ✅ Yes ⚠️ Varies by tool ⚠️ Limited ✅ Yes
Custom fields ⚠️ Manual ✅ Yes ⚠️ Limited ⚠️ Limited ✅ Yes
Timestamps preserved ❌ No ✅ With explicit created_at ⚠️ Varies ❌ No ✅ Yes
Engineering effort Minimal High (1–3 weeks) Low (hours to configure) Low None
Best for Abandoning old data Dev teams with bandwidth Standard schemas Post-migration sync Teams prioritizing speed and accuracy

Choosing the Right Approach

  • Small business (<5K tickets, no custom fields): Third-party tool or CSV if history is expendable.
  • Mid-market (5K–50K tickets, custom fields, attachments): API-based migration or managed service.
  • Enterprise (50K+ tickets, multi-brand, compliance requirements): Staged ETL with a local staging database, or managed migration. Do not attempt CSV.
  • Low engineering bandwidth: Managed service.
  • Ongoing coexistence needed: Middleware for forward sync after a bulk historical migration.

Pre-Migration Planning

Before writing a single line of code, execute a strict data audit.

  1. Audit your Zammad data. Count tickets, users, organizations, articles, attachments, and custom fields. Use GET /api/v1/tickets?only_total_count=true for ticket counts. For Zammad Cloud, confirm that all extraction endpoints are accessible — some Cloud instances may restrict admin-level API access compared to self-hosted.
  2. Identify unused data. Old ticket states, orphaned organizations, test accounts, suspended or merged users. Define a cutoff date if appropriate (e.g., "Only migrate tickets updated in the last 3 years"). Don't migrate garbage.
  3. Define scope. All history or a time window? Closed tickets? Knowledge base articles? CRM-style data mirrored into Zammad?
  4. Map custom fields. Export Zammad's object attributes via GET /api/v1/object_manager_attributes and match each to a Kayako custom field. Watch for type mismatches: Zammad supports tree selects and external data source fields that have no direct Kayako equivalent. (admin-docs.zammad.org)
  5. Choose a migration strategy:
    • Big bang: Migrate everything in one cutover window. Simplest, but requires a maintenance window (typically 2–6 hours depending on volume).
    • Phased delta: Migrate historical data first (>30 days old), validate, then migrate recent data. Run a final delta sync on cutover weekend.
    • Incremental: Continuously sync until parity, then cut over. Most complex but lowest risk.
  6. Plan your rollback. Keep Zammad running in read-only mode until Kayako is confirmed. Prepare a rollback script that bulk-deletes all cases created by your migration API user — Kayako's DELETE /api/v1/cases/{id} endpoint handles individual case deletion, but there is no documented bulk delete endpoint, so your script must iterate through created case IDs. Note that Kayako automations triggered on imported cases (e.g., auto-replies, SLA timers) may have already fired and cannot be cleanly reversed.
  7. Back up Zammad. If self-hosted, take a full PostgreSQL database backup and an Elasticsearch snapshot before beginning extraction.
Info

If your support team uses Zammad as a pseudo-CRM, split the project in two. Migrate support history into Kayako. Keep sales and marketing objects in the CRM, and carry only reference IDs or read-only context that agents need. Kayako documents cases, forms, types, users, and organizations — it is not a pipeline system.

Data Model and Object Mapping

Object Equivalencies

  • Organizations (Zammad) → Organizations (Kayako): Direct 1:1 mapping. Load before users and cases.
  • Users (Zammad) → Users (Kayako): Zammad mixes agents and customers in the User object, distinguished by roles. Match by email to avoid duplicates. Zammad allows users without email addresses in some configurations (e.g., Twitter integrations) — generate placeholder emails for these.
  • Tickets (Zammad) → Cases (Kayako): The core container for a customer issue.
  • Articles (Zammad) → Posts/Notes (Kayako): Public articles become case replies. Articles with internal: true must be routed to Kayako's notes endpoint (/api/v1/cases/{id}/notes).
  • Groups (Zammad) → Teams (Kayako): Zammad groups often function as departments and support nesting (e.g., Support::Billing::Refunds). Kayako's Team model is flat — nested group structures must be flattened, typically by mapping to the leaf group name or concatenating the path.
  • Tags → Tags: Direct map. Kayako uses comma-separated tags.

Field-Level Mapping

Zammad Field Kayako Field Transform Notes
ticket.number legacy_id Store Zammad ticket number as Kayako's legacy_id for traceability
title subject Direct map
state_id status_id Map: new→New, open→Open, pending reminder/pending close→Pending, closed→Closed. Status IDs are instance-specific — query GET /api/v1/cases/statuses to get your Kayako instance's actual IDs. Create custom statuses in Kayako first if needed.
priority_id priority_id Query GET /api/v1/cases/priorities to get Kayako's priority IDs. Verify label alignment between platforms.
group_id assigned_team_id Create teams first, build lookup map
owner_id assigned_agent_id Match by email; only if agent exists in Kayako
customer_id requester_id Match or create by email
organization_id organization_id Load orgs first, build lookup map
tags tags Direct map; comma-separated on Kayako side
created_at created_at Must be explicitly passed. Bulk import endpoint documents historical timestamp support. Without explicit passing, Kayako stamps with current server time.
updated_at updated_at Must be explicitly passed
article.body posts [].contents or reply contents Sanitize HTML encoding; extract inline base64 images
article.internal Route to Notes API true → POST to /api/v1/cases/{id}/notes, not replies
article.attachments attachment_file_ids or multipart upload Upload files first via Files API for bulk import
Custom ticket/user/org fields field_values [...] Pre-create fields and map types carefully. Verify option values match exactly for select/dropdown fields.

Custom Field Constraints

Kayako supports custom fields on cases, users, and organizations, but the type system is narrower than Zammad's. Kayako's documented field types include text, textarea, checkbox, select, date, file, numeric, decimal, yes/no, cascading select, and regex. Zammad's tree selects, external data source fields, and group-level custom fields do not have direct equivalents — these must be flattened, split, or dropped with a documented decision. (admin-docs.zammad.org, help.kayako.com)

A Zammad select (dropdown) field must be created in Kayako with the exact same option values before migration, or the API will reject the payload with a validation error. Query your Kayako custom fields via the API after creation to confirm option values and field IDs match your mapping table.

Migration Architecture: Extract → Transform → Load

The architecture follows a standard ETL pattern with specific handling for API constraints on both sides.

Extract from Zammad

Zammad's REST API is your extraction source. Key endpoints:

Data Endpoint Notes
Tickets GET /api/v1/tickets?page=X&per_page=Y&expand=true Paginate with page and per_page. Max per_page is 500.
Articles GET /api/v1/ticket_articles/by_ticket/{ticket_id} Returns all articles for a ticket
Attachments GET /api/v1/ticket_attachment/{ticket_id}/{article_id}/{attachment_id} Returns binary content
Users GET /api/v1/users?page=X&per_page=Y&expand=true Includes role, organization, custom fields
Organizations GET /api/v1/organizations?page=X&per_page=Y
Groups GET /api/v1/groups Maps to Kayako Teams
Tags GET /api/v1/tags?object=Ticket&o_id={ticket_id} Per-ticket tags
Custom attributes GET /api/v1/object_manager_attributes Schema definitions for custom fields

Authentication: Use a dedicated API token with admin permissions via Authorization: Token token=YOUR_TOKEN. Create a dedicated migration user rather than using an agent's personal token. (docs.zammad.org)

Use expand=true cautiously. It returns nested objects in a single call but increases payload size significantly and can cause timeouts on large instances (>50K tickets). The safer approach is fetching tickets first with expand=false, then querying articles per ticket. (docs.zammad.org)

Warning

Zammad Search Limit Zammad's search endpoint (/api/v1/tickets/search) has a hard limit of 10,000 results regardless of pagination. If you have more than 10K tickets, do not use the search endpoint for extraction. Paginate through /api/v1/tickets using page and per_page parameters. If the paginated list endpoint also truncates at high page numbers, iterate by ticket ID ranges (e.g., GET /api/v1/tickets?page=1&per_page=100 and track the highest ID returned).

Transform

Store extracted data in a local staging database (PostgreSQL recommended for >10K tickets; SQLite sufficient for smaller sets). Build mapping tables (e.g., Zammad User ID 45 → Kayako User ID 902).

The transform layer handles:

  • State mapping: Zammad states → Kayako statuses. Query GET /api/v1/cases/statuses from your Kayako instance to get actual status IDs — do not hardcode. Zammad's "Pending Reminder" state has no direct equivalent in Kayako's default statuses — create a custom status before migration.
  • Priority mapping: Zammad priorities (1–4) → Kayako priorities (query via API). Verify label alignment.
  • Article → message/note routing: Articles with internal: true go to Kayako's notes endpoint. Public articles become replies. Check the type field as well — article types like note and phone may also need routing decisions.
  • User deduplication: Match users by email address. Create in Kayako if not found. Handle email-less users by generating deterministic placeholder emails (e.g., zammad-user-{id}@placeholder.local).
  • Organization linking: Create Kayako organizations first, then link users.
  • HTML sanitization: Zammad article bodies can contain HTML that renders differently in Kayako. Common issues: double-encoded entities (&amp;amp;), Zammad-specific CSS classes, and inline styles. Test 20–30 tickets visually before the full load.
  • Inline image extraction: Zammad users frequently paste images directly into the editor. These are stored as base64 strings in <img src="data:image/png;base64,..."> tags or as referenced attachments. Your script must parse the HTML, extract base64 content, upload it to Kayako as an attachment via the Files API, and rewrite the <img> tag to reference the uploaded file URL.
  • Custom field value transformation: Picklist values must match exactly (case-sensitive). Date formats must be normalized to ISO 8601 (YYYY-MM-DDTHH:MM:SSZ). Boolean fields: Zammad uses true/false, Kayako uses yes/no field type.

Load into Kayako

Key Kayako API endpoints for import:

Data Endpoint Notes
Organizations POST /api/v1/organizations Create before users. Response: {"data": {"id": ...}}
Users POST /api/v1/users Create customer/agent users. Response includes id in data object.
Cases POST /api/v1/cases.json Creates a case with initial message
Bulk cases POST /api/v1/bulk/cases.json Batch creation — up to 200 cases per request. Supports legacy_id and historical timestamps.
Replies POST /api/v1/cases/{id}/replies Add subsequent public messages
Notes POST /api/v1/cases/{id}/notes Internal notes (separate from messages)
File uploads POST /api/v1/files Upload first, then reference attachment_file_ids in case creation
Case deletion DELETE /api/v1/cases/{id} For rollback — no bulk delete endpoint; must iterate

Authentication: Use Basic Auth or OAuth. Kayako returns HTTP 429 with a Retry-After header when rate limits are exceeded. Exact per-minute limits are not publicly documented — in practice, sustained bursts above ~60 requests/minute often trigger throttling, though this varies by Kayako plan and instance. Build exponential backoff into every API call. (developer.kayako.com)

The load order is strictly:

  1. Organizations
  2. Users (with org links)
  3. Cases (with first article as initial contents)
  4. Replies and Notes (subsequent articles, in chronological order)
  5. Attachments (uploaded via Files API, referenced in case/reply payloads)
Info

Timestamp Override If you do not explicitly pass the historical created_at timestamp when creating a Kayako Case, Kayako will stamp it with the current server time. The bulk cases endpoint explicitly documents support for historical created_at and updated_at on both cases and nested posts. For single-case creates via POST /api/v1/cases.json, verify in your Kayako sandbox that created_at overrides are accepted — some Kayako plans or API versions may ignore this field on single creates. (developer.kayako.com)

Danger

Kayako's PUT-only update model: Kayako does not support PATCH. Any update via PUT must include all required fields, or omitted fields may silently revert to defaults. For multi-select custom fields, Kayako's documentation explicitly warns that omitted values are removed on update — never update field values without sending the full intended set. This is especially dangerous during post-migration corrections. (developer.kayako.com)

Step-by-Step Migration Process

Step 1: Extract Organizations and Users

Extract organizations first (they are dependencies for users), then users. Paginate through all records.

import requests
import time
import json
 
ZAMMAD_URL = "https://your-zammad.example.com"
ZAMMAD_TOKEN = "your-api-token"
headers = {"Authorization": f"Token token={ZAMMAD_TOKEN}"}
 
def extract_all_paginated(endpoint, per_page=100):
    """Extract all records from a paginated Zammad endpoint.
    
    Note: This is illustrative. Production scripts should add:
    - Exponential backoff on 429/5xx responses
    - Batch checkpointing to resume from last successful page
    - Dead-letter logging for failed requests
    """
    page = 1
    all_records = []
    while True:
        resp = requests.get(
            f"{ZAMMAD_URL}{endpoint}?page={page}&per_page={per_page}&expand=true",
            headers=headers
        )
        resp.raise_for_status()
        batch = resp.json()
        if not batch:
            break
        all_records.extend(batch)
        page += 1
        time.sleep(0.2)  # Basic rate limiting
    return all_records
 
orgs = extract_all_paginated("/api/v1/organizations")
users = extract_all_paginated("/api/v1/users")
tickets = extract_all_paginated("/api/v1/tickets")
 
# Persist to staging database for transform phase
with open("staging/orgs.json", "w") as f:
    json.dump(orgs, f)
with open("staging/users.json", "w") as f:
    json.dump(users, f)
with open("staging/tickets.json", "w") as f:
    json.dump(tickets, f)

Step 2: Extract Tickets with Articles and Attachments

For each ticket, fetch its articles. For each article with attachments, download the binary content.

import os
 
def extract_ticket_articles(ticket_id):
    resp = requests.get(
        f"{ZAMMAD_URL}/api/v1/ticket_articles/by_ticket/{ticket_id}",
        headers=headers
    )
    resp.raise_for_status()
    return resp.json()
 
def extract_attachment(ticket_id, article_id, attachment_id):
    resp = requests.get(
        f"{ZAMMAD_URL}/api/v1/ticket_attachment/{ticket_id}/{article_id}/{attachment_id}",
        headers=headers
    )
    resp.raise_for_status()
    return resp.content  # binary
 
# Extract articles and attachments for all tickets
for ticket in tickets:
    tid = ticket["id"]
    articles = extract_ticket_articles(tid)
    
    for article in articles:
        if article.get("attachments"):
            for att in article["attachments"]:
                binary = extract_attachment(tid, article["id"], att["id"])
                path = f"staging/attachments/{tid}/{article['id']}/{att['id']}"
                os.makedirs(os.path.dirname(path), exist_ok=True)
                with open(path, "wb") as f:
                    f.write(binary)
    
    time.sleep(0.2)  # Rate limiting

Step 3: Load Organizations and Users into Kayako

Create organizations first, then users. Capture every returned Kayako ID and build your mapping tables.

KAYAKO_URL = "https://your-domain.kayako.com"
KAYAKO_AUTH = ("agent@example.com", "password")
 
org_map = {}   # zammad_org_id -> kayako_org_id
user_map = {}  # zammad_user_id -> kayako_user_id
 
def kayako_post(endpoint, payload, max_retries=5):
    """POST to Kayako with exponential backoff on rate limiting.
    
    Production enhancement: add dead-letter queue for records that 
    fail after max_retries, and batch checkpointing for resumability.
    """
    for attempt in range(max_retries):
        resp = requests.post(
            f"{KAYAKO_URL}{endpoint}",
            json=payload, auth=KAYAKO_AUTH
        )
        if resp.status_code == 429:
            wait = int(resp.headers.get("Retry-After", 2 ** attempt * 10))
            time.sleep(wait)
            continue
        resp.raise_for_status()
        return resp.json()
    raise Exception(f"Failed after {max_retries} retries: {endpoint}")
 
for org in orgs:
    payload = {"name": org["name"]}
    result = kayako_post("/api/v1/organizations", payload)
    # Kayako response structure: {"data": {"id": <int>, ...}}
    org_map[org["id"]] = result["data"]["id"]
 
for user in users:
    email = user.get("email")
    if not email:
        # Generate deterministic placeholder for email-less users
        email = f"zammad-user-{user['id']}@placeholder.local"
    
    payload = {
        "name": f"{user.get('firstname', '')} {user.get('lastname', '')}".strip() or email,
        "email": email,
        "organization_id": org_map.get(user.get("organization_id")),
    }
    result = kayako_post("/api/v1/users", payload)
    user_map[user["id"]] = result["data"]["id"]
 
# Persist mapping tables for resumability
with open("staging/org_map.json", "w") as f:
    json.dump(org_map, f)
with open("staging/user_map.json", "w") as f:
    json.dump(user_map, f)

Step 4: Transform and Load Cases

Query Kayako's status and priority endpoints to build accurate ID maps. Use the first article as the case's initial contents, then add subsequent articles as replies or notes.

# Query Kayako for actual status and priority IDs — do not hardcode
def get_kayako_statuses():
    resp = requests.get(f"{KAYAKO_URL}/api/v1/cases/statuses", auth=KAYAKO_AUTH)
    resp.raise_for_status()
    statuses = resp.json()["data"]
    return {s["label"].lower(): s["id"] for s in statuses}
 
def get_kayako_priorities():
    resp = requests.get(f"{KAYAKO_URL}/api/v1/cases/priorities", auth=KAYAKO_AUTH)
    resp.raise_for_status()
    priorities = resp.json()["data"]
    return {p["label"].lower(): p["id"] for p in priorities}
 
kayako_statuses = get_kayako_statuses()
kayako_priorities = get_kayako_priorities()
 
# Build state map dynamically
# Adjust these mappings based on your Kayako instance's actual status labels
ZAMMAD_STATE_TO_KAYAKO = {
    "new": kayako_statuses.get("new"),
    "open": kayako_statuses.get("open"),
    "pending reminder": kayako_statuses.get("pending"),
    "pending close": kayako_statuses.get("pending"),
    "closed": kayako_statuses.get("closed"),
}
 
def create_kayako_case(ticket, articles, user_map, team_map):
    first_article = articles[0]
    status_id = ZAMMAD_STATE_TO_KAYAKO.get(
        ticket.get("state", "open"), kayako_statuses.get("open")
    )
    
    payload = {
        "subject": ticket["title"],
        "contents": first_article.get("body", ""),
        "requester_id": user_map.get(ticket["customer_id"]),
        "channel": "MAIL",
        "status_id": status_id,
        "priority_id": ticket.get("priority_id", 2),
        "assigned_team_id": team_map.get(ticket.get("group_id")),
        "legacy_id": f"zammad-{ticket['id']}",
        "created_at": ticket["created_at"],
        "updated_at": ticket["updated_at"],
    }
    result = kayako_post("/api/v1/cases", payload)
    return result["data"]["id"]

Step 5: Add Replies and Notes

Skip the first article (already used as case contents) and iterate remaining articles in chronological order. Route internal articles to the notes endpoint.

def add_replies_and_notes(case_id, articles):
    for article in articles[1:]:
        if article.get("internal", False):
            kayako_post(
                f"/api/v1/cases/{case_id}/notes",
                {
                    "body_text": article["body"],
                    "created_at": article.get("created_at"),
                }
            )
        else:
            kayako_post(
                f"/api/v1/cases/{case_id}/replies",
                {
                    "contents": article["body"],
                    "created_at": article.get("created_at"),
                }
            )
        time.sleep(0.5)  # Conservative pacing to avoid 429s

Step 6: Handle Attachments

Kayako uses a two-step file pattern for bulk import: upload files to the Files API first, then reference their IDs via attachment_file_ids in the case payload. For single-case creates, use multipart/form-data to upload attachments directly with the case or reply. (developer.kayako.com)

Download attachments from Zammad's /api/v1/ticket_attachment/{ticket_id}/{article_id}/{attachment_id} endpoint. Implement retry logic for every file upload — failed uploads create valid cases with broken attachment links. After upload, verify the file count per case matches the expected count from Zammad.

def upload_attachment_to_kayako(file_path, filename):
    with open(file_path, "rb") as f:
        resp = requests.post(
            f"{KAYAKO_URL}/api/v1/files",
            files={"file": (filename, f)},
            auth=KAYAKO_AUTH
        )
    if resp.status_code == 429:
        time.sleep(int(resp.headers.get("Retry-After", 30)))
        return upload_attachment_to_kayako(file_path, filename)
    resp.raise_for_status()
    return resp.json()["data"]["id"]

Step 7: Validate

After loading, run validation checks against both systems. See the Validation section below.

Knowledge Base Migration

Knowledge base migration is a separate workstream from ticket/case migration but often required for completeness.

Zammad Knowledge Base structure: Categories → Answers, with optional multilingual variants per answer. Each answer has a body (HTML), optional attachments, and a visibility state (published, internal, draft).

Kayako Help Center structure: Sections → Articles, with optional locales. Articles have a body (HTML), status (published, draft), and category assignment.

Migration approach:

  1. Extract KB categories via GET /api/v1/knowledge_bases/{id}/categories and answers via GET /api/v1/knowledge_bases/{id}/answers.
  2. Create Kayako Help Center sections to match Zammad categories.
  3. Create articles within sections, mapping Zammad answer bodies to Kayako article bodies.
  4. Handle multilingual content: if Zammad answers have multiple locale variants, create separate Kayako articles per locale or use Kayako's locale support if available on your plan.
  5. Re-upload images and rewrite internal links (Zammad KB URLs → Kayako Help Center URLs).

Key limitation: Internal links between KB articles will break. Build a URL rewriting map (old Zammad KB URL → new Kayako Help Center URL) and run a find-and-replace across all migrated article bodies.

Edge Cases and Failure Modes

  • Duplicate users. Zammad allows users without email addresses in some configurations (e.g., Twitter integrations, phone-only contacts). Kayako enforces strict email uniqueness. Generate deterministic placeholder emails (e.g., zammad-user-{id}@placeholder.local) for these profiles. For users with emails, deduplicate on email (case-insensitive), not display name. (admin-docs.zammad.org)
  • Internal notes becoming public. If your script posts internal articles to Kayako's replies endpoint instead of the notes endpoint, those notes become visible to customers. This is the most common data exposure mistake in helpdesk migrations. Mitigate by checking both article.internal == true and article.type == "note" — belt and suspenders.
  • Inline images. Zammad users frequently paste images directly into the editor. These are stored as base64 strings in the HTML body (<img src="data:image/png;base64,...">) or as referenced attachments. Your script must: (a) parse the HTML with a library like BeautifulSoup, (b) extract base64 content from data: URIs, (c) upload each image to Kayako via the Files API, (d) rewrite the <img> tag's src to reference the uploaded file URL.
  • Zammad article types vary. Article types include email, note, phone, web, chat, twitter status, twitter dm, facebook feed post, and others depending on installed channels and add-ons. Do not assume every article is a customer-visible email — check the type and internal flag on every article. (docs.zammad.org)
  • Kayako archived cases. Closed Kayako cases older than 30 days are omitted from the default GET /api/v1/cases response unless you add archived=1 as a query parameter. This distorts post-cutover audits done weeks later — your validation script must include this parameter. (help.kayako.com)
  • Attachment size limits. Kayako plan-dependent file size limits may reject large attachments (common limits: 20MB–50MB per file depending on plan). Log any that exceed the limit for manual handling rather than letting the entire case creation fail.
  • Reassignment audit trails. If a Zammad ticket was reassigned multiple times, the historical audit log entries are stored separately from articles. Focus on migrating the messages and current assignment, not the metadata audit trail — Kayako does not have an equivalent audit log import endpoint.
  • Enum field updates. Kayako warns that omitted multi-select values are removed on PUT updates. Never update field values without sending the full intended set. (developer.kayako.com)
  • Missing file uploads. Failed attachment uploads create valid cases with broken history links. Implement retry logic (at least 3 retries with exponential backoff) for every file upload, and verify attachment counts per case after migration against expected counts from Zammad. (developer.kayako.com)
  • HTML rendering differences. Zammad's rich text editor and Kayako's editor handle whitespace, line breaks, and CSS differently. Common issues: <br> vs <p> tag handling, CSS classes stripped by Kayako's sanitizer, table layouts collapsing. Visual QA on 20–30 tickets before full migration is essential.

Limitations and Constraints

  • Kayako's narrower schema. Kayako does not support the same depth of nested groups and roles as Zammad. You must flatten nested group hierarchies into flat teams. Kayako has no arbitrary custom object layer — it supports custom fields on cases, users, and organizations only. Group-level custom fields in Zammad have no target in Kayako. (help.kayako.com)
  • API rate limits. Both systems enforce rate limits. Kayako's exact per-minute limits are not publicly documented; empirically, sustained traffic above ~60 requests/minute often triggers 429 responses, but this varies by plan. Attempting to push 100K tickets sequentially without batching or backoff will take weeks and generate thousands of retry cycles. (developer.kayako.com)
  • Bulk import sizing. Kayako's bulk case import endpoint accepts up to 200 cases per request. For 50K tickets, that's a minimum of 250 bulk requests for cases alone, plus separate requests for replies, notes, and attachments. (developer.kayako.com)
  • CSV flows are capped and lossy. Zammad reporting downloads stop at 6,000 entries and do not include article bodies, internal notes, or attachments — they are not a full case-thread export mechanism. (admin-docs.zammad.org)
  • Status mapping gaps. Zammad's "Pending Reminder" state has no direct equivalent in Kayako's default statuses. Create a custom status in Kayako before migration.
  • No bulk delete in Kayako. Rollback requires iterating through DELETE /api/v1/cases/{id} for each created case. For a 50K-case migration, rollback itself can take hours.
  • CRM data stays in the CRM. Kayako is not a pipeline system. Leads, opportunities, and marketing attributes should remain in your CRM with only reference IDs carried into Kayako.

Validation and Testing

Never execute a production migration without a validation phase. Run these checks in your sandbox migration first, then repeat after production cutover.

  1. Record count comparison. Ensure Total Zammad Tickets - Excluded Tickets = Total Kayako Cases. Compare users, organizations, and attachment counts. Use Kayako's archived=1 parameter when counting cases to include closed cases older than 30 days.
  2. Field-level spot checks. Sample 50 tickets across different states, priorities, and ages. Verify subject, status, assignee, requester, tags, and custom field values match. Include at least 5 tickets with custom field values and 5 with multi-select fields.
  3. Attachment integrity. Download 10 attachments from Kayako and compare file hashes (SHA-256) against Zammad originals. Open them to verify they're not corrupted or 0-byte files. Verify the attachment count per case matches the source.
  4. Internal note verification. Confirm notes appear in Kayako's notes panel, not as public messages. Test by viewing a migrated case as a customer-role user — internal notes should not be visible.
  5. Relationship integrity. Verify user→organization links and case→assignee links survived the migration. Sample 20 users and confirm their organization association matches the source.
  6. Thread ordering. Confirm articles appear in chronological order within each case. Check at least 10 cases with >5 articles each.
  7. Inline image rendering. Open 10 cases that contained pasted images in Zammad and verify they render correctly in Kayako.
  8. UAT with agents. Have 2–3 agents work in Kayako for a day using migrated data before full cutover. Ask them to search for specific old tickets, verify customer context, and confirm they can update migrated cases without issues.
  9. Rollback plan. Keep Zammad running in read-only mode for at least 2 weeks post-migration. Maintain a list of all Kayako case IDs created during migration for potential rollback via DELETE /api/v1/cases/{id}.

For a deeper validation framework, use the Post-Migration QA Checklist.

Post-Migration Tasks

Data migration is only half the move. After cutover:

  • Rebuild automations. Zammad triggers, schedulers, macros, and SLAs cannot be migrated programmatically. Recreate them manually in Kayako's automation builder. Document each Zammad automation and its Kayako equivalent.
  • Recreate canned responses. Zammad Text Modules become Kayako Macros. Export Text Modules via GET /api/v1/text_modules and recreate in Kayako.
  • Reconfigure integrations. Any Zammad webhooks, Zapier connections, or custom API integrations must be rewired to Kayako endpoints. Monitor API error logs for 48 hours post-launch to catch any integrations still pushing data to Zammad.
  • Agent training. Kayako's conversation-centric UI differs significantly from Zammad's ticket-list model. Budget 1–2 days for team onboarding, focusing on: case search and filtering, internal note workflow, macro usage, and SLA visibility.
  • Monitor for data gaps. Run daily record count comparisons for the first week. Check for missing attachments, broken user links, or orphaned cases.
  • Update knowledge base. If you migrated knowledge base content, verify that article formatting, images, and internal links survived the transfer. Run a link checker across all migrated KB articles.
  • Disable Kayako automations during migration. If not already done, ensure any auto-reply or SLA automation rules are disabled during the migration load to prevent thousands of automated responses from firing on imported historical cases. Re-enable after load completes.

Best Practices

  • Run a sandbox migration first. Push a 5% data sample to a Kayako sandbox environment before touching production. Validate field mapping, timestamp preservation, and internal note routing.
  • Freeze configuration. Implement a change freeze on Zammad custom fields and groups two weeks before migration. Any schema changes after your field mapping is built will invalidate the transform layer.
  • Store source IDs everywhere. Use Kayako's legacy_id field and consider a custom field for the original Zammad ticket number. Every imported record should be traceable back to its source.
  • Make your script idempotent. Separate extract, transform, and load into independently rerunnable stages. Persist batch status and progress markers. Log every source ID and target ID pair. Use upsert logic where possible to handle reruns without creating duplicates.
  • Communicate downtime. Even with delta sync, enforce a 2-hour window where agents do not update tickets to ensure the final delta completes cleanly. Notify customers if your SLA guarantees response times that might be impacted.
  • Keep a written decision log. Document every dropped or flattened field, every custom status created, every mapping choice. This log is invaluable for post-migration debugging and compliance audits.
  • Validate incrementally. Check results after each phase (orgs, users, cases, replies), not just at the end. Catching a mapping error after loading 50K cases is far more costly than catching it after the first 100.
  • Disable Kayako automations during load. Auto-reply rules, SLA timers, and assignment automations should be disabled during the migration import to prevent them from firing on historical data.

Realistic Timelines

Scenario Ticket Volume DIY Timeline With Managed Service
Small team, simple data <5K tickets, no custom fields 3–5 days 1–2 days
Mid-size, moderate complexity 5K–50K tickets, custom fields, attachments 1–3 weeks 3–5 days
Enterprise, complex schema 50K+ tickets, multi-brand, knowledge base 3–6 weeks 1–2 weeks

These estimates include development, testing, sandbox validation, and cutover — not just script writing. The DIY timeline is dominated by edge case handling, not initial development.

When a Managed Migration Service Makes Sense

Building a migration pipeline in-house makes sense when your team has API experience, your dataset is small (<5K tickets), and your timeline is flexible. The math changes when:

  • 10K+ tickets with attachments = weeks of engineering time for extraction, upload retry logic, and verification
  • Custom fields and picklist mapping = complexity that compounds with volume as each type mismatch requires investigation
  • Zero-downtime requirement = need a delta-sync strategy with final cutover sync, not a one-shot load
  • Rate limit management = Kayako's undocumented limits require empirical tuning of backoff parameters

The hidden cost isn't the first script — it's the debugging, edge case handling (inline images, email-less users, HTML sanitization), attachment retries, and the first week of production support after go-live. Building an in-house ETL pipeline for a one-time helpdesk migration is rarely a strategic use of engineering time when the pipeline has no reuse value.

Why ClonePartner: We treat data migration as an engineering discipline. For helpdesk moves, that means engineer-owned field mappings, ordered dependency loading (organizations → users → cases → posts), timestamp preservation that overrides default API behaviors, internal note routing to the correct endpoints, custom field type transforms, and delta-sync for zero-downtime cutover. We handle the extraction, transformation, rate-limit pacing, and post-load validation. See how we run migrations.

Frequently Asked Questions

Is there a native migration tool from Zammad to Kayako?
No. Zammad has a built-in migrator for importing FROM Kayako into Zammad, but there is no reverse path. Zammad-to-Kayako requires API-based extraction and loading via custom scripts or a managed migration service.
Can I migrate Zammad tickets to Kayako using CSV?
Only for small, flat datasets. Zammad's reporting download is limited to 6,000 entries and does not export individual articles, internal notes, or attachments. Kayako has no native bulk CSV import for cases. Full thread history and timestamps require API-based migration.
How do I migrate Zammad internal notes to Kayako?
Zammad stores internal notes as articles with 'internal: true'. In Kayako, notes are a separate resource — you must POST them to the /api/v1/cases/{id}/notes endpoint, not the messages/replies endpoint. Getting this wrong exposes internal notes to customers.
How long does a Zammad to Kayako migration take?
For a DIY approach: 3–5 days for under 5K tickets with no custom fields, 1–3 weeks for 5K–50K tickets with custom fields and attachments. A managed migration service typically completes in 3–5 business days regardless of complexity.
Does Kayako preserve historical timestamps during import?
The bulk cases endpoint (POST /api/v1/bulk/cases.json) explicitly supports historical created_at and updated_at on both cases and posts. For single-case creates, verify that your Kayako plan accepts created_at overrides — if not, all imported cases will be stamped with the current date.

More from our Blog