Skip to content

Zammad to Crisp Migration: The Technical Guide

Technical guide to migrating from Zammad to Crisp. Covers API extraction, data model mapping, rate limits, attachment handling, edge cases, and cutover strategy.

Roopendra Talekar Roopendra Talekar · · 24 min read
Zammad to Crisp 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

Zammad to Crisp Migration: The Technical Guide

Migrating from Zammad to Crisp is a custom API project. There is no native import path between the two platforms — no built-in Zammad exporter that produces Crisp-compatible files, and no Zammad adapter in Crisp's official crisp-import-conversations tool. Every ticket, article, attachment, contact, and tag must be extracted from the Zammad REST API, structurally transformed from a ticket-article model to a conversation-message model, and loaded into Crisp via its REST API while managing daily quotas and pagination limits on both sides.

This guide covers data model differences, API constraints, object mapping, extraction and loading code, error handling, idempotency, encoding issues, edge cases, validation, and cutover strategy. It is written for engineers and ops leads who need to move from Zammad to Crisp without losing historical data.

All references target Zammad REST API v1 and Crisp REST API V1. Verify endpoints against current docs before implementation — both platforms update their APIs periodically.

Info

TL;DR — Zammad to Crisp Migration

Zammad structures data as Tickets containing Articles. Crisp structures data as Conversations containing Messages. No native adapter exists. You must extract data via Zammad's API (or PostgreSQL for self-hosted instances), convert HTML emails to plain text or use Crisp's original message payload, stage attachments for upload, and map discrete tickets to Crisp's conversation model — all while managing Crisp's daily API quotas, implementing retry/backoff logic, and designing for idempotent re-runs.

If you are still deciding whether the platform move makes sense, read Crisp vs Zammad: Architecture, TCO & Migration Guide. This article assumes the decision is made and focuses on execution.

Why Teams Move from Zammad to Crisp

The most common operational triggers:

  • Moving away from self-hosting: Self-hosted Zammad requires infrastructure management, security patching, and Elasticsearch maintenance. Teams that no longer want to manage a Ruby on Rails stack and PostgreSQL instances move to Crisp's fully managed SaaS.
  • Chat-first support model: Zammad is email-centric by design. Teams shifting to live chat, WhatsApp, Instagram DMs, and Messenger as primary channels find Crisp's unified inbox purpose-built for that workflow.
  • Simpler tooling: Crisp bundles live chat, CRM, knowledge base, chatbot builder, and campaigns in one platform. Teams consolidating away from Zammad + separate chat + separate CRM find that appealing.
  • Per-agent pricing vs. flat-rate: Zammad's hosted plans charge per agent; Crisp offers flat-rate workspace pricing. At scale (15+ agents), the cost structure differs materially. Check current pricing on both vendor sites before modeling TCO — published figures change frequently.

Zammad vs Crisp: Data Model Differences That Matter

Before writing any migration code, you need to understand the structural mismatch. Getting this wrong means corrupted conversations, lost context, or broken relationships.

Zammad is an ITIL-compliant helpdesk. Its core object is the Ticket — a discrete, closable work item with its own state, priority, owner, and thread of Articles (messages, notes, emails). Zammad natively understands email concepts like CCs, BCCs, and subject lines.

Crisp is a customer messaging platform. Its core object is the Contact Profile, which holds a continuous Conversation timeline. While Crisp supports a ticketing add-on, its primary interface treats all interactions with a single user as a unified stream of Messages. Crisp explicitly says its ticketing layer exists but is not the foundation of the workflow.

Concept Zammad Crisp
Core unit Ticket (discrete, closable) Conversation (continuous thread)
Messages Articles (typed: email, note, phone, chat) Messages (typed: text, file, note, field)
Customer record User (linked to Organization) Contact/People profile
Company grouping Organization (standalone object) Company data embedded on contact profile
Agent grouping Groups (permission-based) Inbox / routing rules
Labels Tags (freeform) Segments (labels on contacts + conversations)
Custom data Object attributes on tickets, users, orgs, groups Custom data keys on contacts; conversation metadata
Ticket states New, Open, Pending Reminder, Pending Close, Closed Unresolved, Pending, Resolved
Priority 1 low, 2 normal, 3 high No native priority field
Internal notes internal: true on articles Notes (not available on Free plan)
Attachments Binary, fetched per article via API File messages or attachments on messages
Email threading Full Reply-To/References header chain No native email threading model
CC/BCC Stored per article No native CC/BCC fields
SLA tracking Native SLA timers with escalation events No native SLA system
Audit log Full audit trail per object No equivalent

Key Structural Gaps

Organizations vanish. Zammad maintains standalone Organization objects with their own custom attributes and user memberships. Crisp has no equivalent first-class entity — company data is stored as fields directly on the contact profile. If your Zammad instance uses organizations heavily for routing, reporting, or shared-ticket behavior, that structure will flatten during migration. (docs.zammad.org)

Priority has no home. Zammad tickets carry priority levels (1–3). Crisp conversations have no native priority field. You can store the original priority in conversation metadata or a custom data key, but it won't drive any native Crisp behavior.

Article types collapse. Zammad distinguishes between email articles, phone notes, internal notes, chat messages, and social posts — each with its own type field. In Crisp, everything becomes a message typed as text, file, note, or a few others. You need a mapping function to convert Zammad article types while preserving the distinction between customer-facing and internal content.

Email threading breaks. Zammad preserves full Reply-To and References header chains, which enables email clients to thread conversations correctly. Crisp's conversation model does not expose these headers. Reply-to threading continuity from legacy tickets cannot be restored post-migration.

CC/BCC fields disappear. Crisp's conversation model does not natively support CC and BCC in the way email-centric ticketing does. That context must be explicitly preserved during migration (covered in the edge cases section).

SLA data is unrecoverable. Zammad's SLA timers, escalation thresholds, and breach events have no structural equivalent in Crisp. This data should be archived externally before cutover — it cannot be represented in the Crisp data model.

Participant limits vary by plan. Crisp restricts the number of participants per conversation depending on your subscription plan. If your Zammad tickets routinely have multiple CC'd recipients, check your plan's participant cap before designing the mapping logic.

Zammad API: Extraction Constraints

Zammad's REST API is the primary way to extract full ticket data programmatically. The built-in CSV export from the Zammad UI covers only basic ticket metadata — it does not include article bodies, attachments, or full conversation threads. (docs.zammad.org)

Authentication

Zammad supports HTTP Basic Auth, API tokens, and OAuth2. For migration scripts, API tokens with ticket.agent permission are the cleanest approach — they avoid exposing user credentials and provide read access to all tickets the agent can see.

curl -H "Authorization: Token token=YOUR_API_TOKEN" \
     https://your-zammad.example.com/api/v1/tickets?page=1&per_page=100

Pagination and Counts

Zammad paginates all list endpoints using page and per_page parameters, with a default page size of 100 objects. Use with_total_count=true on search endpoints to get the total count for progress tracking. (docs.zammad.org)

For self-hosted instances, the API bottleneck is usually your own database performance. Consider bypassing the API entirely and exporting directly from PostgreSQL to speed up extraction.

import requests
import time
 
base_url = "https://your-zammad.example.com/api/v1"
headers = {"Authorization": "Token token=YOUR_API_TOKEN"}
 
def fetch_with_retry(url, max_retries=5):
    """Fetch a URL with exponential backoff on failure."""
    for attempt in range(max_retries):
        try:
            resp = requests.get(url, headers=headers, timeout=30)
            if resp.status_code == 429:
                wait = 2 ** attempt
                print(f"Rate limited. Waiting {wait}s before retry {attempt+1}/{max_retries}")
                time.sleep(wait)
                continue
            resp.raise_for_status()
            return resp.json()
        except requests.exceptions.RequestException as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)
    raise RuntimeError(f"Failed after {max_retries} retries: {url}")
 
def fetch_all_tickets():
    page = 1
    all_tickets = []
    while True:
        data = fetch_with_retry(
            f"{base_url}/tickets?page={page}&per_page=100&expand=true"
        )
        if not data:
            break
        all_tickets.extend(data)
        page += 1
    return all_tickets
Warning

Using expand=true on the ticket list can cause timeouts on large instances. If that happens, query /api/v1/ticket_articles/by_ticket/{ticket_id} for each ticket individually.

Extracting Articles and Attachments

Articles are fetched per ticket via /api/v1/ticket_articles/by_ticket/{ticket_id}. Each article includes sender info, body (HTML or plain text), internal flag, type, and an attachments array with metadata. Attachment binary content requires a separate HTTP request per file.

def fetch_articles(ticket_id):
    return fetch_with_retry(
        f"{base_url}/ticket_articles/by_ticket/{ticket_id}?expand=true"
    )
 
def fetch_attachment(article_id, attachment_id):
    resp = requests.get(
        f"{base_url}/ticket_attachment/{article_id}/{attachment_id}",
        headers=headers,
        timeout=60
    )
    resp.raise_for_status()
    return resp.content  # binary
Info

For large Zammad instances, attachment downloads are the extraction bottleneck. Each file is a separate HTTP request. Budget 2–5 seconds per attachment and parallelize with a thread pool (4–8 workers) to keep extraction time reasonable.

For a deeper look at Zammad's export capabilities, see How to Export Data from Zammad.

Why Not Backup Scripts?

Zammad ships backup scripts, but they produce full instance dumps designed for disaster recovery on the same host and version. Partial backup and restore are not supported. They are the wrong format for selectively transforming helpdesk objects into Crisp conversations. (docs.zammad.org)

Crisp API: Loading Constraints

Crisp's REST API is the only way to import conversation data. The Crisp dashboard supports CSV import for contacts only — not conversations or messages.

Authentication and Token Types

Crisp offers two token types for API access:

  • Website tokens: Instant setup, single-workspace access, no approval needed. Suitable for smaller imports.
  • Plugin tokens: Generated via the Crisp Marketplace, support multi-workspace access and configurable daily quotas. Required for larger migrations. You must create a custom plugin, install it on your workspace, and request scopes like website:conversation:initiate, website:conversation:messages, and website:conversation:states.
curl https://api.crisp.chat/v1/website/{website_id}/conversation \
     -X POST \
     --user "{identifier}:{key}" \
     --header "X-Crisp-Tier: plugin" \
     --header "Content-Type: application/json"

Rate Limits: Daily Quotas, Not Per-Minute

Crisp's rate limiting model is unusual. Plugin tokens operate on a daily quota system — you can burst requests freely within a day until you exhaust your allocation, then you're blocked until the quota resets. If you hit quota limits mid-migration, you can request a daily quota increase through the Crisp Marketplace. Plan for this before starting — approval is not instant. (github.com)

When Crisp returns a quota error, the HTTP response is typically 429 Too Many Requests with a body indicating quota exhaustion rather than a per-second rate limit. Your retry logic must distinguish between transient server errors (retry with backoff) and quota exhaustion (halt until reset, or request quota increase).

Common Crisp API error codes to handle:

HTTP Status Cause Action
429 Daily quota exhausted Halt; wait for quota reset or request increase
404 on session_id Conversation not found (race condition or bad ID) Log and skip; do not retry indefinitely
400 / 422 Malformed message payload (encoding, missing required field) Log full payload; fix transform logic; do not retry as-is
401 Invalid or expired credentials Halt; rotate credentials
503 Crisp server error Retry with exponential backoff (max 5 attempts)
Tip

Estimate your total API calls before migrating. Crisp's import tool documentation estimates the formula as roughly (n × 5) + (n × m), where n is the number of conversations and m is the average messages per conversation. A 10,000-ticket Zammad instance with an average of 8 articles each would require ~130,000 API calls minimum. Use this to project how many days your daily quota will require and request an increase proactively.

The crisp-import-conversations Tool

Crisp maintains an official open-source import tool (crisp-im/crisp-import-conversations) that accepts JSON, JSONL, CSV, or XML files and loads them into Crisp. It includes built-in adapters for Zendesk, Gorgias, Help Scout, Tidio, GrooveHQ, and WHMCS.

There is no Zammad adapter. You have two options:

  1. Write a custom adapter for the import tool that transforms Zammad's export into the format Crisp expects.
  2. Build a standalone migration script that reads from Zammad's API and writes to Crisp's API directly.

Option 2 gives you more control over error handling, progress tracking, and retry logic — which matters for production migrations.

Warning

Know the importer's built-in limitations. In the current source, text and note bodies are clipped at 2,000 characters, oversized inline data-URI images are stripped from original HTML, note messages are skipped on the Free plan, and extra participants are capped by plan. If your Zammad history includes long internal notes or large HTML email bodies, fork the importer or write your own loader. (raw.githubusercontent.com)

Staging Workspace Strategy

Before running against your production Crisp workspace, create a separate Crisp workspace to use as a staging environment. Run the full migration pipeline against staging first. Validate counts, message ordering, and attachment integrity there before touching production. Crisp does not provide a native "undo import" — if you load malformed data into production, cleanup requires manual deletion or additional API scripting. A staging workspace costs the same as production (plan-dependent) but is essential for catching transform bugs before they contaminate live data.

Step-by-Step Migration Architecture

Step 1: Extract Users and Organizations from Zammad

Pull all users via /api/v1/users and organizations via /api/v1/organizations using pagination. Build a local lookup table mapping Zammad user IDs → email addresses and organization IDs → company names.

def build_user_map():
    user_map = {}
    page = 1
    while True:
        resp = fetch_with_retry(
            f"{base_url}/users?page={page}&per_page=100"
        )
        if not resp:
            break
        for user in resp:
            user_map[user["id"]] = {
                "email": user.get("email"),
                "name": f"{user.get('firstname', '')} {user.get('lastname', '')}".strip(),
                "org_id": user.get("organization_id")
            }
        page += 1
    return user_map

Step 2: Import Contacts into Crisp

Crisp supports CSV import for contacts through the dashboard (Essentials plan and above). Export your Zammad users to CSV with columns for email, first name, last name, and company, then import via Crisp's Contacts → Actions → Import flow. For custom data fields, use the Crisp REST API to set custom data on each contact profile after the CSV import. (help.crisp.chat)

Crisp's own migration guidance recommends loading contacts first so agents can recognize customers immediately when conversations are backfilled. (help.crisp.chat)

Warning

Crisp merges contacts by email address. If a Zammad user's email already exists in Crisp (e.g., from live chat activity during the migration window), the import will update the existing profile rather than creating a duplicate. This is usually desirable, but verify that merge behavior won't overwrite data you need to preserve.

Step 3: Extract Tickets and Articles from Zammad (with Checkpointing)

Iterate through all Zammad tickets, and for each ticket, fetch its articles. Store the combined data locally (JSON files or a SQLite staging database) before loading into Crisp. Checkpointing is critical — production migrations fail mid-run due to network errors, quota exhaustion, or process interruption. Without checkpointing, you restart from zero and risk duplicating conversations.

import json
import sqlite3
import os
 
def init_checkpoint_db(db_path="migration_progress.db"):
    conn = sqlite3.connect(db_path)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS progress (
            zammad_ticket_id INTEGER PRIMARY KEY,
            crisp_session_id TEXT,
            status TEXT,  -- 'extracted', 'loaded', 'failed'
            error_message TEXT,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.commit()
    return conn
 
def is_already_loaded(conn, ticket_id):
    row = conn.execute(
        "SELECT crisp_session_id FROM progress WHERE zammad_ticket_id=? AND status='loaded'",
        (ticket_id,)
    ).fetchone()
    return row[0] if row else None
 
def mark_loaded(conn, ticket_id, session_id):
    conn.execute(
        "INSERT OR REPLACE INTO progress (zammad_ticket_id, crisp_session_id, status) VALUES (?,?,?)",
        (ticket_id, session_id, "loaded")
    )
    conn.commit()
 
def mark_failed(conn, ticket_id, error):
    conn.execute(
        "INSERT OR REPLACE INTO progress (zammad_ticket_id, status, error_message) VALUES (?,?,?)",
        (ticket_id, "failed", str(error))
    )
    conn.commit()
 
def extract_all(user_map):
    os.makedirs("staging", exist_ok=True)
    tickets = fetch_all_tickets()
    for ticket in tickets:
        articles = fetch_articles(ticket["id"])
        ticket["_articles"] = articles
        ticket["_customer"] = user_map.get(ticket.get("customer_id"), {})
        with open(f"staging/ticket_{ticket['id']}.json", "w") as f:
            json.dump(ticket, f)

Step 4: Transform Zammad Tickets → Crisp Conversations

Each Zammad ticket becomes one Crisp conversation. Each Zammad article becomes one Crisp message.

Mapping ticket states:

Zammad State Crisp State
new, open unresolved
pending reminder, pending close pending
closed, merged resolved

Mapping article types to message types:

Zammad Article Type Crisp Message Type Notes
email (external) text Set from = user if customer, operator if agent
note (internal) note Requires Essentials plan or above
phone text Prefix with [Phone note] to preserve context
web / chat text Direct mapping
Article with attachments file or text + separate file message See attachment handling below

Mapping message origin: Zammad articles have a sender_id that maps to sender types (Agent, Customer, System). In Crisp, each message has a from field — either user (customer) or operator (agent). System-generated articles should be mapped to operator to avoid confusing the conversation thread.

Idempotency and external IDs: Crisp does not accept a caller-supplied external ID for conversations — session_id values are generated by Crisp on conversation creation. To run the migration idempotently (safe to re-run after failure without duplicating conversations), store each Zammad ticket ID → Crisp session_id mapping in your checkpoint database immediately after creation. On re-run, check the checkpoint before creating a new conversation. If a session_id exists for a given ticket ID, skip creation and proceed to message loading.

Preserving source IDs: Store the Zammad ticket number and ID in Crisp conversation custom data fields on every imported thread. This enables lookup, delta sync, and debugging.

HTML body sanitization pipeline: Zammad email bodies arrive as HTML with inline styles, embedded images encoded as base64 data URIs, blockquotes from forwarded threads, and potentially non-UTF-8 characters from legacy email encoding. Feed every body through a sanitization pipeline before creating Crisp messages:

from bs4 import BeautifulSoup
import re
 
def sanitize_html_body(html, max_blockquote_depth=2, max_length=1900):
    """
    Convert Zammad HTML article body to Crisp-safe plain text.
    Handles: encoding issues, base64 inline images, deep blockquotes, length limits.
    """
    # 1. Normalize encoding — force UTF-8, replace invalid bytes
    if isinstance(html, bytes):
        html = html.decode("utf-8", errors="replace")
 
    # 2. Strip base64 inline images (data URIs) — these bloat payloads and break Crisp's importer
    html = re.sub(r'src="data:[^"]{100,}"', 'src="[inline-image-stripped]"', html)
 
    # 3. Parse and extract text with structure preserved
    soup = BeautifulSoup(html, "html.parser")
 
    # 4. Truncate deep blockquote chains (forwarded email threads)
    blockquotes = soup.find_all("blockquote")
    for bq in blockquotes:
        depth = sum(1 for _ in bq.parents if _.name == "blockquote")
        if depth >= max_blockquote_depth:
            bq.replace_with("[Previous message thread truncated]")
 
    # 5. Convert links to markdown-style text
    for a in soup.find_all("a", href=True):
        a.replace_with(f"{a.get_text()} ({a['href']})")
 
    # 6. Extract text, preserving line breaks
    text = soup.get_text(separator="\n").strip()
 
    # 7. Collapse excessive whitespace
    text = re.sub(r'\n{3,}', '\n\n', text)
 
    # 8. Enforce Crisp importer character limit (2,000 chars — leave 100 char margin)
    if len(text) > max_length:
        text = text[:max_length] + "\n[Message truncated — full content in original field]"
 
    return text

A minimal target payload for a Crisp conversation:

{
  "id": "zammad-ticket-22019",
  "user": {
    "email": "david@example.com",
    "name": "David Bell"
  },
  "data": {
    "zammad_ticket_id": 19,
    "zammad_ticket_number": "22019",
    "zammad_priority": "3 high",
    "zammad_organization": "Acme Corp"
  },
  "messages": [
    {
      "from": "user",
      "text": "Original customer message",
      "date": 1736200000000,
      "origin": "email",
      "original": {
        "type": "text/html",
        "content": "<p>Original email body...</p>"
      }
    },
    {
      "from": "operator",
      "note": "Internal Zammad note",
      "date": 1736200300000,
      "user": { "name": "Agent Name" }
    }
  ],
  "state": "resolved"
}

Note the original field on messages — Crisp can store the original HTML email body separately from the displayed text content. This preserves full HTML fidelity for agents who need it while keeping the chat view clean. (docs.crisp.chat)

Step 5: Load Conversations into Crisp (with Retry and Checkpointing)

For each transformed ticket, the load sequence is:

  1. Check checkpoint — skip if already loaded
  2. Create conversation via POST /v1/website/{website_id}/conversation
  3. Set conversation metadata — subject, segments (mapped from tags), custom data (Zammad ticket ID, priority, organization)
  4. Set conversation contact — link to the customer's email
  5. Send messages in chronological order
  6. Set final conversation state
  7. Record session_id in checkpoint database
from crisp_api import Crisp
import time
 
client = Crisp()
client.set_tier("plugin")
client.authenticate(CRISP_IDENTIFIER, CRISP_KEY)
 
def crisp_api_call_with_retry(fn, *args, max_retries=5, **kwargs):
    """Wrap any Crisp API call with exponential backoff."""
    for attempt in range(max_retries):
        try:
            return fn(*args, **kwargs)
        except Exception as e:
            error_str = str(e)
            # Quota exhaustion — halt, do not retry
            if "429" in error_str and "quota" in error_str.lower():
                raise RuntimeError("Crisp daily quota exhausted. Halt migration until quota resets.")
            # Malformed payload — do not retry, log and skip
            if "400" in error_str or "422" in error_str:
                raise ValueError(f"Malformed payload (will not retry): {error_str}")
            # Transient error — retry with backoff
            if attempt < max_retries - 1:
                wait = 2 ** attempt
                print(f"Crisp API error (attempt {attempt+1}/{max_retries}), retrying in {wait}s: {e}")
                time.sleep(wait)
            else:
                raise
 
def load_conversation(website_id, ticket_data, checkpoint_conn):
    ticket_id = ticket_data["id"]
 
    # Idempotency check
    existing_session = is_already_loaded(checkpoint_conn, ticket_id)
    if existing_session:
        print(f"Ticket {ticket_id} already loaded as {existing_session}, skipping.")
        return existing_session
 
    try:
        # Create conversation
        conversation = crisp_api_call_with_retry(
            client.website.create_new_conversation, website_id
        )
        session_id = conversation["session_id"]
 
        # Set contact email and metadata
        customer_email = ticket_data["_customer"].get("email")
        if customer_email:
            crisp_api_call_with_retry(
                client.website.update_conversation_metas,
                website_id, session_id,
                {
                    "email": customer_email,
                    "nickname": ticket_data["_customer"].get("name", ""),
                    "data": {
                        "zammad_ticket_id": ticket_id,
                        "zammad_ticket_number": ticket_data.get("number"),
                        "zammad_priority": ticket_data.get("priority"),
                        "zammad_organization": ticket_data.get("_customer", {}).get("org_name", "")
                    }
                }
            )
 
        # Send messages in chronological order
        for article in sorted(ticket_data["_articles"], key=lambda a: a["created_at"]):
            msg_type = "note" if article.get("internal") else "text"
            origin = "operator" if article.get("sender") == "Agent" else "user"
            raw_body = article.get("body", "")
            clean_body = sanitize_html_body(raw_body)
 
            msg_payload = {
                "type": msg_type,
                "from": origin,
                "origin": "chat",
                "content": clean_body,
                "timestamp": int(parse_timestamp(article["created_at"]))
            }
 
            # Preserve original HTML in the original field
            if "<" in raw_body:
                msg_payload["original"] = {
                    "type": "text/html",
                    "content": raw_body[:50000]  # Crisp has payload size limits
                }
 
            crisp_api_call_with_retry(
                client.website.send_message_in_conversation,
                website_id, session_id, msg_payload
            )
 
        # Set final state
        if ticket_data.get("state") in ["closed", "merged"]:
            crisp_api_call_with_retry(
                client.website.change_conversation_state,
                website_id, session_id, {"state": "resolved"}
            )
 
        mark_loaded(checkpoint_conn, ticket_id, session_id)
        return session_id
 
    except Exception as e:
        mark_failed(checkpoint_conn, ticket_id, e)
        raise
Danger

Message ordering matters. Always sort articles by created_at before sending. Crisp conversations display messages in insertion order. If you load them out of sequence, the conversation history will be unreadable.

Warning

Block outbound emails during import. Crisp's official import repo recommends contacting support before bulk imports so outgoing emails can be temporarily suppressed. Without this, Crisp may send notifications to customers for every imported message. (github.com)

Edge Cases and Failure Modes

Moving text is the easy part. These edge cases dictate the quality of the migration.

HTML Body Handling

Zammad stores email bodies as rich HTML with inline styles, embedded images, and signature blocks. Crisp's chat UI supports limited formatting. Pushing raw HTML into Crisp renders as code blocks.

Two approaches:

  1. Strip and convert: Use the sanitization pipeline shown above — BeautifulSoup to parse, strip unsupported tags, handle blockquote depth limits, convert links, enforce character limits, and decode encoding issues before submission.
  2. Use the original payload: Crisp messages can store original content as text/html in the original field alongside a plain-text content field. This preserves full HTML fidelity for agents who need it while keeping the chat view clean. (docs.crisp.chat)

In practice, use both: clean sanitized text for the display body, original HTML preserved in the original field.

Encoding edge cases: Legacy Zammad instances often contain email bodies in Latin-1, Windows-1252, or ISO-8859-1 encoding from pre-Unicode email systems. Always decode with errors="replace" before processing. Base64 data URIs embedded in <img> tags must be stripped — they exceed payload size limits and are blocked by Crisp's importer. The sanitization pipeline above handles both.

Attachment Handling

You cannot upload binary files directly through Crisp's message creation endpoint. Attachments require a multi-step process:

  1. Download from Zammad via /api/v1/ticket_attachment/{ticket_id}/{article_id}/{id}
  2. Upload to Crisp's storage: request a pre-signed S3 URL from POST /v1/website/{website_id}/bucket/url/generate, then PUT the binary to that URL
  3. Reference the resulting URL as a file message type in the Crisp conversation, including name, url, and MIME type

This process is synchronous and slow. For instances with large attachment volumes, parallelize this step with a thread pool and include retry logic around the S3 PUT step.

CC and BCC Data

Crisp does not natively support CC and BCC fields. When a Zammad ticket contains CCd recipients, that context is lost unless explicitly handled.

Best practice: extract the cc and bcc strings from Zammad article metadata and either prepend them to the message body as visible context or inject them as an internal note. Example: [Migrated Metadata: CC'd to engineering@example.com].

Agent Mapping and Inactive Users

Zammad agent replies are tied to a Zammad Agent ID. In Crisp, you must map this to a Crisp Operator ID. If an agent has left the company and does not have a Crisp seat, you cannot attribute the message to them without paying for a dummy seat.

The standard workaround: map all inactive agent replies to a single "Legacy System" operator in Crisp and prepend the original agent's name to the message body: [Legacy Agent: John Doe] answered: ...

Merged and Linked Tickets

Zammad supports ticket merging and linking. Crisp has no equivalent concept. For merged tickets, migrate only the parent and include a note referencing the original ticket number. For linked tickets, add a text note with related ticket numbers for agent reference.

Tags → Segments

Zammad tags are freeform strings attached to tickets. Crisp segments are labels applied to conversations and contacts. Map each unique Zammad tag to a Crisp segment — but importing thousands of unique tags will clutter your segment list. Consider filtering to the top 20–50 most-used tags and discarding or consolidating the rest.

Custom Object Attributes

Zammad supports custom attributes on tickets, users, organizations, and groups. Crisp supports custom data keys on contact profiles and conversation metadata. Map ticket-level custom fields to conversation metadata, and user-level custom fields to contact custom data. Organization-level custom fields have no natural home in Crisp — flatten them onto relevant contact profiles or accept the data loss.

Use Zammad's object_manager_attributes endpoint to discover what exists, then triage what becomes live Crisp data versus archived records. (docs.zammad.org)

Multi-Channel Zammad Tickets

Zammad supports tickets that contain articles from multiple channels — for example, a ticket that begins as an email exchange, then has a Twitter DM reply, then an internal phone note. In Crisp, all messages in a conversation share a single channel context. When migrating multi-channel tickets, map each article's original channel to a metadata field or text prefix (e.g., [via Twitter DM]) to preserve the channel provenance.

Timestamps and Timezone Handling

Zammad stores timestamps in UTC (ISO 8601 format). Crisp's message creation accepts a Unix timestamp in milliseconds. Convert carefully:

from datetime import datetime, timezone
 
def parse_timestamp(ts_string):
    """Convert Zammad ISO 8601 UTC timestamp to Crisp millisecond Unix timestamp."""
    dt = datetime.fromisoformat(ts_string.replace("Z", "+00:00"))
    return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000)

An off-by-one timezone error will scramble conversation chronology. Test with a known timestamp before running at scale.

Validation and Reconciliation

After loading, validate the migration with these checks:

  1. Count match: Total conversations in Crisp should equal total tickets extracted from Zammad (minus intentionally excluded ones like spam or test tickets).
  2. Message count per conversation: Spot-check 20–30 conversations. Article count in Zammad should match message count in Crisp for each.
  3. Contact linkage: Verify conversations are linked to the correct customer email. Broken email mapping means orphaned conversations agents can't find by customer search.
  4. Attachment integrity: Download a sample of migrated attachments from Crisp and compare file sizes to Zammad originals.
  5. State accuracy: Filter Crisp conversations by resolved and verify they correspond to closed tickets in Zammad.
  6. Timestamp ordering: Open 10 migrated conversations and verify messages appear in correct chronological order.
  7. Failed ticket review: Query your checkpoint database for all records with status='failed' and address each one before declaring migration complete.
def reconcile(zammad_tickets, checkpoint_conn):
    z_count = len(zammad_tickets)
    loaded = checkpoint_conn.execute(
        "SELECT COUNT(*) FROM progress WHERE status='loaded'"
    ).fetchone()[0]
    failed = checkpoint_conn.execute(
        "SELECT zammad_ticket_id, error_message FROM progress WHERE status='failed'"
    ).fetchall()
 
    print(f"Zammad tickets: {z_count}")
    print(f"Successfully loaded: {loaded}")
    print(f"Failed: {len(failed)}")
    if failed:
        print("Failed ticket IDs and errors:")
        for ticket_id, error in failed:
            print(f"  Ticket {ticket_id}: {error}")
Info

Imported history visibility. Crisp says imported conversations are visible to your team in the Inbox, but users do not automatically see past imported exchanges in the chatbox widget. If you need customer-facing transcript continuity, design that separately rather than discovering it after go-live. (docs.crisp.chat)

What You'll Lose in the Migration

Be explicit with stakeholders before migration begins. The following data either has no Crisp equivalent or cannot survive the model transformation:

Data Type Zammad Crisp Outcome
SLA timers and escalation events Native SLA system No equivalent Archive externally before cutover
Ticket priority 1 low / 2 normal / 3 high No native field Storable as metadata; no routing effect
Organization hierarchy Standalone Organization objects Contact-level company fields only Flattened
Agent assignment history Full audit trail Current assignment only Lost
Ticket linking / merging relationships Native merge/link No equivalent Convert to text notes
Email threading continuity Full Reply-To/References chain No email threading model Reply-to chain broken post-migration
CC/BCC recipients Stored per article No native fields Must be preserved manually in message body or notes
Core Workflow trigger history Automation execution logs No equivalent Lost
Audit logs Full object-level audit trail No equivalent Archive to cold storage before cutover
Knowledge base articles Zammad KB Crisp Helpdesk (separate system) Manual migration; Crisp's KB importer does not list Zammad as a supported source
Multi-channel article provenance Article type field No channel distinction per message Must be encoded as text prefix

Crisp's built-in KB importer requires a public source URL and does not list Zammad as a supported provider. Migrate KB content manually or via the Crisp REST API. (help.crisp.chat)

Zammad audit logs, detailed SLA traces, and merged-ticket lineage should be archived in cold storage or a lightweight lookup interface before you decommission Zammad. (docs.zammad.org)

Delta Sync and Cutover Strategy

Helpdesks are live environments. Customers don't stop sending emails while you run a multi-day migration script.

1. Run the Initial Sync

Execute the full extraction and load process. This moves the bulk of your historical data. Record the exact UTC timestamp when extraction started. Store it — you'll need it for the delta query.

2. Validate in Staging

Run the full pipeline against your staging Crisp workspace first. Spot-check complex tickets: HTML emails, internal notes, attachments, CC recipients, organization-linked customers, and old closed tickets. If that sample fails, volume will only hide the bug.

3. Pilot with a Hostile Sample

Do not pilot only clean tickets. Specifically include: HTML emails with deep blockquote chains, internal notes with 3,000+ character bodies, articles with large attachments, tickets with multiple CCs, organization-linked customers, merged tickets, and multi-channel tickets (email + social channel in the same thread). These are the cases most likely to surface transform bugs.

4. Run the Delta Sync

Write a modified extraction script that only queries Zammad tickets where updated_at is greater than your initial sync timestamp. This pulls new tickets and new replies on old tickets. Your checkpoint database and the stored Zammad ticket ID → Crisp session_id mapping enable upsert behavior: if a ticket was already loaded, send only new articles as new messages rather than duplicating the conversation.

5. Cut Channels Last

Only after counts, attachments, state mapping, and spot checks pass should you repoint support email, widget traffic, or portal flows to Crisp. Route your MX records and support widgets away from Zammad. Run the delta sync one final time to catch the last few minutes of activity. Keep outbound email suppression in place until the final delta import is complete.

For guidance on running this without disrupting live support, see Zero-Downtime Help Desk Data Migration.

6. Archive What Crisp Cannot Hold

Zammad audit logs, SLA traces, merged-ticket lineage, and checklist completion history are best preserved in cold storage or a lightweight lookup UI — not forced into Crisp's data model where they have no native representation.

Migration Timeline

For a typical Zammad instance with 5,000–20,000 tickets:

Phase Duration Notes
API token setup + test extraction 1 day Verify permissions, test pagination
Full extraction to staging 1–3 days Depends on ticket volume and attachment count
Transform + mapping script development 2–4 days Including HTML sanitization, encoding, edge case handling
Load into Crisp (staging workspace) 1–2 days Test with a subset first; validate checkpoint behavior
Validation + reconciliation 1 day Automated + manual spot checks; review all failed records
Production load + delta sync 1–2 days Run during low-traffic hours
Total 7–13 days Engineering time, not calendar days

Request Crisp quota increases at the start of the project — not when you hit the limit mid-migration.

When Not to Migrate Full History

Not every team needs a complete historical migration. If your Zammad instance has 50,000+ tickets and agents rarely reference anything older than 6 months, consider:

  • Migrating only recent tickets (last 6–12 months) and archiving older data in exported JSON or a lightweight search interface.
  • Migrating contacts only via CSV import and starting fresh with conversations in Crisp.
  • Hybrid approach: Migrate contacts + the last N months of tickets, and keep Zammad in read-only mode for historical reference during a transition period.

The engineering time and API quota cost of migrating 100,000 tickets with full attachment history versus 5,000 recent tickets can differ by 10x. For very large instances, selective migration is often the right call on pure cost grounds.

Making It Happen

Zammad to Crisp is a data engineering project regardless of approach. The lack of a native adapter, combined with the fundamental gap between ticket-based and conversation-based data models, means there is no shortcut that preserves full fidelity. The work is: checkpointed extraction, encoding-aware HTML sanitization, idempotent loading with retry logic, attachment staging, and structured validation.

If your engineering team has the bandwidth, the mapping logic and code above will get you there. If your team needs to focus on core product work rather than building and maintaining throwaway migration scripts, bring in specialists.

At ClonePartner, we've built migration pipelines between helpdesk platforms — including Zammad to Zendesk, Zammad to Help Scout, and Crisp to Gorgias — and we handle extraction, transformation, quota management, validation, and edge cases so your engineering team doesn't have to build throwaway migration scripts.

Frequently Asked Questions

Can I export Zammad tickets directly into Crisp?
No. There is no native import path between Zammad and Crisp. Crisp's official import tool supports adapters for Zendesk, Gorgias, Help Scout, Tidio, GrooveHQ, and WHMCS — but not Zammad. You must extract data via the Zammad REST API, transform it to match Crisp's conversation-message schema, and load it via the Crisp REST API or a custom adapter.
What data is lost when migrating from Zammad to Crisp?
SLA data and escalation history, ticket priority (no native Crisp field), organization hierarchy (flattened to contact-level company fields), agent assignment history, ticket linking and merging relationships, Core Workflow trigger logs, and checklist/audit log details. Knowledge base articles must be migrated separately.
How long does a Zammad to Crisp migration take?
For a typical instance with 5,000–20,000 tickets, expect 7–13 days of engineering time covering extraction, transformation script development, loading, validation, and cutover. Larger instances with heavy attachments take proportionally longer due to per-file API calls and quota constraints.
What are the Crisp API rate limits for data imports?
Crisp plugin tokens use a daily quota system rather than per-minute rate limits, allowing burst requests until the daily allocation is exhausted. For large imports, request a quota increase through the Crisp Marketplace before starting. Estimate total API calls as roughly (n × 5) + (n × m), where n is conversations and m is average messages per conversation.
How do Zammad ticket states map to Crisp conversation states?
Crisp supports three states: unresolved, pending, and resolved. A common mapping is new/open → unresolved, pending reminder/pending close → pending, and closed → resolved. Merged tickets typically need a reference note since Crisp has no merge concept.

More from our Blog