Skip to content

How to Export Data from Zammad: Methods, API Limits & Portability

Learn every method to export data from Zammad — REST API extraction, database dumps, Rails console, pagination limits, and data portability gaps.

Raaj Raaj · · 19 min read
How to Export Data from Zammad: Methods, API Limits & Portability
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

How to Export Data from Zammad: Methods, API Limits & Portability

Zammad has no built-in bulk export for tickets. The Reporting UI can generate .xlsx spreadsheets, but those are capped at 6,000 entries and contain flat metadata — no conversation threads, no internal notes, no attachments. If you need full ticket histories, internal notes, timestamps, attachments, and customer relationships intact, you're looking at API scripting, direct database access, or Rails console queries.

For SaaS users, the REST API is the only viable extraction path. For self-hosted users, you also have direct PostgreSQL access and Zammad's built-in backup workflows.

This guide covers every viable extraction method, the API constraints you'll hit, and the specific failure modes that trip up teams preparing for a migration or archival.

Verified against: Zammad 6.x/7.x REST API and official documentation as of mid-2026. Zammad's API behavior can shift between major versions — always verify endpoints against your installed version before scripting.

If you're exporting Zammad data as part of a platform migration, see our technical guides for Zammad to Zendesk, Zammad to Help Scout, or Zammad to Tidio.

Method Selection: Which Approach to Use

Before writing a single line of code, pick the right extraction path. The wrong choice multiplies complexity.

Scenario Recommended Method
Zammad-hosted (SaaS) instance REST API only — no database or console access
Self-hosted, need full speed, no transformation Direct PostgreSQL dump
Self-hosted, need targeted queries or selective export Rails console
Any instance, migrating to a different help desk REST API (transformation-friendly JSON output)
Validation, sanity checks, quick counts Reporting UI export (≤6,000 rows, metadata only)
Incremental delta sync REST API + updated_at filter or webhooks

Decision rule: Use the API unless you control the server and need raw throughput. The API is the only method that produces data in a format directly useful for transforming into another platform's data model. Database dumps are faster but require you to reverse-engineer the relational schema before any transformation work begins.

Zammad's Data Model: What You're Actually Exporting

Before writing extraction scripts, understand how Zammad stores data. Zammad follows an API-first philosophy — the web UI is a single-page application acting as an API client. Every piece of data visible in the interface is accessible via the REST API. (docs.zammad.org)

The core objects:

Object API Endpoint Notes
Tickets /api/v1/tickets Core record. Contains metadata: state, priority, group, owner, customer, timestamps.
Ticket Articles /api/v1/ticket_articles/by_ticket/{id} Individual messages, internal notes, and system updates within a ticket. Zammad calls these "articles," not "comments."
Attachments /api/v1/ticket_attachment/{ticket_id}/{article_id}/{id} Binary files attached to articles. Downloaded per-attachment.
Users /api/v1/users Agents, admins, and customers share the same table, differentiated by roles.
Organizations /api/v1/organizations Company records linked to users.
Groups /api/v1/groups Agent routing groups (supports nested hierarchy).
Tags /api/v1/tags Free-form labels applied to tickets.
Knowledge Base /api/v1/knowledge_bases/{kb_id}/categories and .../answers Articles, categories, and translations.
Custom Fields /api/v1/object_manager_attributes Custom field definitions and schema.
Time Accounting /api/v1/tickets/{ticket_id}/time_accountings Time tracking entries per ticket.

When exporting a ticket, you're not pulling a single row. You're pulling the ticket metadata, querying its associated articles, downloading attachments tied to those articles, and mapping user IDs back to customer and agent profiles.

Info

Zammad returns JSON for all API responses. There is no native CSV or XML export from the API — you'll need to transform JSON to your target format in your extraction scripts.

Token Permissions Reference

Under-scoped tokens are the most common cause of silent, incomplete exports. Create an access token under User Profile → Token Access. The required permissions per endpoint:

Data Type Required Permission(s) Notes
Tickets (own group) ticket.agent Only tickets in groups the token owner belongs to
Tickets (all groups) ticket.agent + admin group membership Token owner must be in all relevant groups, or have admin role
Internal notes ticket.agent with group access Internal notes are invisible to under-scoped tokens with no error returned
Users & Organizations admin.user Required for reading all user records
Knowledge Base (read) knowledge_base.reader Read published articles
Knowledge Base (drafts + internal) knowledge_base.editor Required to see unpublished and internal-only KB content
Custom field definitions admin.object_manager Access to /api/v1/object_manager_attributes
Time accounting ticket.agent Same group restrictions as tickets apply

Critical warning: Zammad's ticket visibility is group-scoped. An under-scoped token returns a technically successful HTTP 200 response with a subset of tickets — there is no error, no warning, and no indication that records are missing. Always verify total ticket counts before and after scoping your token. (docs.zammad.org)

For a full export, the token owner should be an admin assigned to all groups, with ticket.agent, admin.user, knowledge_base.editor, and admin.object_manager permissions enabled.

Method 1: REST API Extraction (Primary Method)

The REST API is the official and most complete way to export data from Zammad. It works for both self-hosted and Zammad-hosted (SaaS) instances.

Authentication

Zammad supports three authentication methods:

  • HTTP Token Authentication (recommended): Create an access token under User Profile → Token Access. Scope it per the permissions table above.
  • HTTP Basic Authentication: Simpler but less secure. Can be disabled at the instance level. Not recommended for bulk exports.
  • OAuth2: For third-party application integrations.
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.example.com/api/v1/tickets?page=1&per_page=100"

The expand=true Parameter

By default, querying a ticket returns a payload full of IDs — customer_id: 42, group_id: 2, state_id: 1. Resolving these IDs normally requires secondary API calls, which rapidly exhausts your request budget.

Zammad solves this with the expand=true parameter. When appended to your GET request, Zammad resolves IDs into human-readable names alongside the raw IDs — "state": "open" instead of just "state_id": 2. It also includes an assets object containing full records of all related Users, Groups, and Organizations referenced in the response.

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

Instead of making hundreds of extra requests to resolve customer names and group labels, you parse the assets dictionary in memory. For a batch of 100 tickets referencing 40 unique users and 10 groups, expand=true eliminates up to 50 additional API calls per page.

Zammad also documents a full=true parameter that returns all related assets plus a total count. The assets block in a full=true response includes complete User, Organization, and Group records keyed by ID — useful for building lookup tables but response sizes can reach several MB per page for heavily-related tickets. Use expand=true as the default for export scripts; reserve full=true for small validation runs or diagnostics. (docs.zammad.org)

Pagination: Hard Limits You Can't Change

Zammad enforces hard limits on the number of returned objects per request, and you cannot raise them. Pagination uses page and per_page query parameters.

GET /api/v1/tickets?page=1&per_page=100&expand=true
GET /api/v1/tickets?page=2&per_page=100&expand=true

Key pagination behaviors:

  • Default and maximum per_page is 100 for the /api/v1/tickets endpoint. Values above 100 are silently clamped to 100 — you do not get an error, you just get 100 records. This is the per-request ceiling you must plan around.
  • Search endpoints cap at ~500 results per server-side configuration, overriding your per_page value.
  • No total count by default. Zammad does not return a total object count unless you add ?with_total_count=true or ?only_total_count=true to your request.
  • ticket_articles/by_ticket/ does NOT support pagination. All articles for a ticket return in a single response regardless of count. This is by design.

Bypassing Elasticsearch Constraints

Many developers try to extract data using Zammad's search endpoint (/api/v1/tickets/search). This is a critical mistake for full database exports.

Zammad's search is powered by Elasticsearch, which enforces a default max_result_window of 10,000 records. If you paginate past 10,000 results using the search endpoint, the API will error out.

To export more than 10,000 tickets, iterate through the standard /api/v1/tickets endpoint using page-based pagination, or write a script that queries tickets by sequentially incrementing the ticket ID (/api/v1/tickets/{id}). Iterating by ID is the safest method for massive instances — it avoids pagination drift entirely if tickets are modified during the export.

Warning

The knowledge base search endpoint (/api/v1/knowledge_bases/search) has a known issue where page and per_page parameters are ignored entirely. Use the category/answer listing endpoints instead of search for bulk KB export.

Rate Limiting

Zammad does not publish formal API rate limits. The practical constraints depend on your deployment:

Deployment Rate Limiting Behavior
Self-hosted Rate limiting is handled by Rack::Attack middleware. The default configuration file is at config/initializers/rack_attack.rb in the Zammad source. Out-of-the-box Zammad does not enable aggressive throttling — the default rules focus on login attempts rather than API throughput. However, unconstrained parallel requests degrade the UI for active agents by competing for database and application server resources.
Zammad Hosted (SaaS) Implicit limits apply but specific thresholds are not publicly documented.

If you exceed allowed request rates, Zammad returns HTTP 429 Too Many Requests. To inspect your self-hosted instance's current Rack::Attack config: cat /opt/zammad/config/initializers/rack_attack.rb. To add an API throttle, add a rule like throttle('api/ip', limit: 300, period: 5.minutes) { |req| req.ip if req.path.start_with?('/api/') } to that file and restart the application server.

For extraction scripts, a conservative starting point is one request per second (1 RPS) for SaaS instances and 2–5 RPS for self-hosted instances with adequate server resources. Monitor server CPU and response times and adjust accordingly.

Throughput and Volume Benchmarks

Practical throughput at 1 request/second with per_page=100:

  • Ticket metadata only (no articles, no attachments): ~360,000 tickets/hour
  • Tickets + articles (1 article fetch per ticket): ~180,000 tickets/hour
  • Tickets + articles + attachments (average 2 attachments per ticket, downloaded synchronously): ~30,000–50,000 tickets/hour depending on attachment size

For a 50,000-ticket instance with articles and attachments, expect 6–12 hours of extraction time at conservative request rates, depending on average attachment size and article volume. Parallelizing attachment downloads with an async queue reduces wall-clock time significantly without increasing API pressure on the ticket/article endpoints.

Extracting Tickets and Articles: Production-Ready Code

A complete ticket export requires multiple API calls per ticket. The following Python sample includes retry logic, exponential backoff, 429 handling, checkpoint resumption, and connection timeout management:

import requests
import time
import json
import os
 
BASE_URL = "https://your-zammad.example.com"
HEADERS = {
    "Authorization": "Token token=YOUR_TOKEN",
    "Content-Type": "application/json"
}
CHECKPOINT_FILE = "export_checkpoint.json"
OUTPUT_FILE = "tickets_export.jsonl"
 
def load_checkpoint():
    if os.path.exists(CHECKPOINT_FILE):
        with open(CHECKPOINT_FILE, 'r') as f:
            return json.load(f)
    return {"last_page": 0, "total_exported": 0}
 
def save_checkpoint(page, total):
    with open(CHECKPOINT_FILE, 'w') as f:
        json.dump({"last_page": page, "total_exported": total}, f)
 
def api_get(url, params=None, max_retries=5):
    """GET with exponential backoff on 429 and transient errors."""
    for attempt in range(max_retries):
        try:
            resp = requests.get(
                url,
                headers=HEADERS,
                params=params,
                timeout=30
            )
            if resp.status_code == 429:
                wait = (2 ** attempt) * 5  # 5s, 10s, 20s, 40s, 80s
                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.ConnectionError as e:
            wait = (2 ** attempt) * 2
            print(f"Connection error: {e}. Retrying in {wait}s")
            time.sleep(wait)
        except requests.exceptions.Timeout:
            print(f"Timeout on attempt {attempt+1}. Retrying.")
            time.sleep(5)
    raise RuntimeError(f"Failed after {max_retries} attempts: {url}")
 
def get_total_ticket_count():
    data = api_get(f"{BASE_URL}/api/v1/tickets", params={"only_total_count": "true"})
    return data.get("total_count", 0)
 
def export_all_tickets():
    checkpoint = load_checkpoint()
    start_page = checkpoint["last_page"] + 1
    total_exported = checkpoint["total_exported"]
 
    total_count = get_total_ticket_count()
    print(f"Total tickets to export: {total_count}. Resuming from page {start_page}.")
 
    with open(OUTPUT_FILE, 'a') as out:
        page = start_page
        while True:
            tickets = api_get(
                f"{BASE_URL}/api/v1/tickets",
                params={"page": page, "per_page": 100, "expand": "true"}
            )
            if not tickets:
                break
 
            for ticket in tickets:
                ticket_id = ticket["id"]
                articles = api_get(
                    f"{BASE_URL}/api/v1/ticket_articles/by_ticket/{ticket_id}",
                    params={"expand": "true"}
                )
                ticket["articles"] = articles
                out.write(json.dumps(ticket) + "\n")
                time.sleep(0.1)  # Throttle article fetches
 
            total_exported += len(tickets)
            save_checkpoint(page, total_exported)
            print(f"Page {page} done. Exported {total_exported}/{total_count} tickets.")
 
            page += 1
            time.sleep(0.5)  # Throttle between ticket pages
 
    print(f"Export complete. Total: {total_exported} tickets.")
 
if __name__ == "__main__":
    export_all_tickets()

The checkpoint file allows safe resumption if the script is interrupted. Output is written as JSON Lines (.jsonl) — one ticket per line — which is memory-efficient for large datasets and easy to stream-process.

Handling Attachments

Attachments are tied to Articles, not Tickets. When you query the articles endpoint, the response includes an attachments array with IDs, filenames, sizes, and MIME metadata.

GET /api/v1/ticket_attachment/{ticket_id}/{article_id}/{attachment_id}
Warning

Do not download attachments synchronously in your main extraction loop. Zammad allows attachments up to 50MB by default. Synchronous downloads will cause your script to hang and time out. Push attachment URLs to a separate queue and download them asynchronously. Note that HTML article bodies can also contain inline attachment references and Content-ID metadata — preserving the binary alone is not enough if you need faithful replay in another help desk. (docs.zammad.org)

A practical pattern: during your ticket/article extraction pass, write attachment metadata (ticket_id, article_id, attachment_id, filename, size, mime_type) to a separate queue file. Run a second asynchronous pass to download binaries, saving them as {ticket_id}/{article_id}/{filename}. This decouples extraction speed from attachment download speed.

Determining Attachment Storage Configuration

On self-hosted instances, Zammad stores attachments either on the filesystem or in the database depending on the storage_provider setting in config/application.rb or the admin configuration. Check the current setting:

grep -r "storage_provider" /opt/zammad/config/

If storage_provider is DB, attachments are stored as blobs in the store_files table. If it is File, they are stored under /opt/zammad/storage/. For cloud deployments, S3-compatible storage is also supported. This affects your backup strategy: a PostgreSQL dump alone won't include filesystem-stored attachments — you need both the DB dump and the storage directory.

Extracting Users and Organizations

Zammad's support documentation confirms that export of users and organizations is only possible via the REST API — there is no UI-based export. CSV import exists for users and organizations in the admin panel, but the reverse does not. (support.zammad.com)

curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.example.com/api/v1/users?page=1&per_page=100&expand=true"
 
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://your-zammad.example.com/api/v1/organizations?page=1&per_page=100&expand=true"

Extracting Knowledge Base Content

The knowledge base API uses a nested structure: Knowledge Base → Categories → Answers. Each answer can have translations for multi-language setups.

Step 1: Get the KB overview

POST /api/v1/knowledge_bases/init

Requires knowledge_base.editor permission. The response returns an object with a knowledge_bases array, each containing id, name, locale, and a category_tree_sorted array listing root category IDs in display order. Record each knowledge base id — you'll need it to walk categories.

Step 2: Walk the category tree

GET /api/v1/knowledge_bases/{kb_id}/categories/{category_id}

Each category response includes a children array of sub-category IDs and an answers array of answer IDs within that category. Recurse into children to walk the full tree. Categories have internal, draft, and published states — knowledge_base.editor is required to see non-published content.

Step 3: Fetch each answer

GET /api/v1/knowledge_bases/{kb_id}/answers/{answer_id}

The answer object includes a translations array. Each translation has a locale, title, body (HTML), and an attachments array for embedded images and file attachments.

Step 4: Download embedded images and attachments from the standard attachments API using the IDs from the answer's attachment list.

Tip

For a full knowledge base export, consider the open-source Zammad Knowledge Base Export tool, which exports the entire KB — including drafts, internal articles, and images — to a directory tree of Markdown files via the REST API.

Webhook-Based Delta Sync

For ongoing incremental sync rather than one-time export, Zammad supports webhooks (Manage → Webhooks). Zammad can fire a webhook payload on ticket creation, article creation, and state changes. This is more reliable than polling updated_at because it eliminates the gap between polling intervals and avoids the pagination drift problem described below.

If webhook delivery fails, Zammad logs delivery attempts but does not guarantee exactly-once delivery — build your consumer to be idempotent (deduplicate by ticket ID + article ID).

For one-time migrations, polling updated_at with a checkpoint is sufficient. For live sync or ongoing archival pipelines, webhooks are the cleaner architecture.

Method 2: Direct Database Access (Self-Hosted Only)

If you run Zammad on your own infrastructure, direct database access is the fastest extraction path. It bypasses API pagination limits and guarantees 100% data fidelity with zero rate-limit concerns.

Starting with Zammad 7, PostgreSQL is the only supported database server (MySQL/MariaDB was supported in version 6 and earlier). The database configuration lives in /opt/zammad/config/database.yml.

PostgreSQL Dump

# Full database dump
pg_dump -U zammad -h localhost zammad_production -F c -f /tmp/zammad_export.dump
 
# Export specific tables to CSV
psql -U zammad -d zammad_production -c \
  "COPY (SELECT * FROM tickets) TO STDOUT WITH CSV HEADER" > tickets.csv
 
psql -U zammad -d zammad_production -c \
  "COPY (SELECT * FROM ticket_articles) TO STDOUT WITH CSV HEADER" > articles.csv
 
psql -U zammad -d zammad_production -c \
  "COPY (SELECT * FROM users) TO STDOUT WITH CSV HEADER" > users.csv

While CSVs are useful for data warehousing and analysis, they're difficult to use for migrating to another help desk due to the complex relational mapping required. We detail those limitations in Using CSVs for SaaS Data Migrations.

Built-in Backup Scripts

Zammad ships backup and restore scripts for package installations, located at /opt/zammad/contrib/backup. These create a PostgreSQL dump plus a tar archive of file attachments stored on disk.

Warning

Zammad's backup scripts come with no warranty and "may not work in your specific use case," per official documentation. They create full dumps only — no incremental backup, no partial backup or restore, and no backup of environment variables. Useful for disaster recovery, but you'll still need to parse and transform the data for migration to another platform. (docs.zammad.org)

For Docker-based installations, backups use a separate workflow and are created at stack start and nightly at 3:00 by default.

When to use each path: Use the backup path when you need disaster recovery, a lab clone, legal archive, or migration into another Zammad instance. Use the API when you need selective extraction, repeatable deltas, or transformation into a different data model. The backup creates a binary PostgreSQL dump optimized for restore-into-Zammad, not for cross-platform transformation.

Zammad Hosted (SaaS) Backups

If you use Zammad Hosted and need a backup file, hosted customers can request one twice per year, with additional backups offered for a fee. (support.zammad.com)

Method 3: Rails Console (Self-Hosted Advanced)

Zammad is a Ruby on Rails application. The Rails console gives you direct ORM access to every object — useful for targeted queries that would be cumbersome via the API, without the HTTP overhead of REST requests.

# Package install
zammad run rails c
 
# Docker
docker compose run --rm zammad-railsserver bundle exec rails c

Once inside, you can run targeted exports:

# Export all tickets with articles to JSONL (memory-efficient for large datasets)
File.open('/tmp/zammad_tickets.jsonl', 'w') do |file|
  Ticket.find_each(batch_size: 1000) do |ticket|
    export_data = ticket.as_json
    export_data['articles'] = ticket.articles.as_json
    file.puts(export_data.to_json)
  end
end
 
# Export tickets updated in the last 30 days
tickets = Ticket.where('updated_at > ?', 30.days.ago)
tickets.each do |t|
  puts "#{t.id},#{t.number},#{t.title},#{t.state.name},#{t.created_at}"
end
 
# Count total records before planning your extraction
puts "Tickets: #{Ticket.count}"
puts "Articles: #{Ticket::Article.count}"
puts "Users: #{User.count}"
puts "Organizations: #{Organization.count}"
puts "Attachments: #{Store.count}"

find_each with batch_size: 1000 loads records in batches rather than all at once, preventing memory exhaustion on large instances. The Rails console has no pagination limits and bypasses Elasticsearch entirely — for 500,000-ticket instances, this is often the only practical extraction path that finishes in reasonable time.

Danger

The Rails console can modify data. Double-check your commands before running, and use a test system first. Read-only queries (select, find, where, count) are safe; anything that calls save, update, or destroy is not.

Method 4: Reporting Export (Spreadsheets Only)

Zammad's Reporting UI can download ticket data to .xlsx. Navigate to the Reporting section, configure a report profile, and download results when the filter is based on Ticket Count.

Two hard constraints:

  • The download is capped at 6,000 entries. (admin-docs.zammad.org)
  • The export contains flat metadata only — ticket number, subject, state, customer, timestamps. No conversation threads, no internal notes, no attachments.

Zammad also warns that granting Reporting permission can expose ticket metadata a user may not otherwise see through normal ticket access. Treat it as a controlled export surface. (admin-docs.zammad.org)

For migrations, Reporting is a validation artifact — useful for sanity-checking counts and date ranges, not the surface you use to reconstruct article history or attachment binaries.

Third-Party ETL Tools

If writing custom scripts isn't realistic, several integration platforms connect to Zammad's API:

  • n8n: Open-source workflow automation with a native Zammad node for extracting tickets, users, and other objects.
  • Synesty Studio: Commercial ETL tool with a Zammad add-on for exporting to CSV, XML, JSON, and Excel.
  • Skyvia: Cloud integration platform offering Zammad-to-PostgreSQL sync and data pipeline design.

These tools abstract away pagination and JSON-to-CSV conversion but add their own limitations — cost, data volume caps, and reduced control over error handling and resumability.

API Constraints and Failure Modes

Pagination Drift

If you're exporting from an active Zammad instance, tickets will be created and updated while your script runs. When paginating with page=1&per_page=100, a new ticket arriving shifts all subsequent records down by one. Your script will either miss a ticket or duplicate one across pages.

To prevent this, sort queries by id in ascending order and use the last seen ID as your cursor. Alternatively, use the ?only_total_count=true call at the start to record expected counts, then reconcile after the run. Or freeze the instance during the extraction window.

Elasticsearch Dependency

Zammad's search endpoints rely on Elasticsearch. If your ES cluster is undersized or unresponsive, search-based exports will fail or time out. Zammad also warns not to expose Elasticsearch publicly without authentication because it may contain sensitive information. Treat Elasticsearch as a search and analytics layer, not as the canonical data store. (docs.zammad.org)

System Notes vs. Human Messages

Zammad records every state change, owner assignment, and tag update as an Article with a specific type_id. When you extract articles, you'll get a large volume of system noise.

Before loading into a new platform, filter the articles. Typically, you only want articles where the type is note, email, web, or phone. In practice, articles with type Activity or system-notifications are usually excluded. Pushing system logs into another help desk clutters the UI and frustrates agents.

The article object's internal field (boolean) distinguishes internal notes (visible to agents only) from external replies (visible to customers). Always preserve and map this field — losing the internal/external distinction is a common migration quality failure.

No Incremental Export API

Unlike Zendesk, which offers a dedicated Incremental Ticket Export endpoint with cursor-based pagination, Zammad does not provide a purpose-built incremental export API. Delta sync is possible, but you build it yourself — checkpoints, sorted queries by updated_at, retries, and idempotent re-runs. (docs.zammad.org)

Zammad API Versioning

Zammad has maintained a single API version (v1) throughout its history as of mid-2026. Breaking changes between major Zammad versions (e.g., v5 → v6 → v7) have occurred at the endpoint behavior level rather than via a versioned API namespace change. The PostgreSQL-only change in v7 is the most significant infrastructure shift. Always test your extraction script against a staging instance of your target Zammad version before running against production.

Data Portability Gaps

Some data requires workarounds or is not extractable through standard channels:

  • Ticket articles ≠ comments elsewhere. Zammad's article model (per-message records with sender type, internal boolean, and separate content types) doesn't map 1:1 to most target platforms. Zendesk uses comments, Help Scout uses threads, Tidio uses conversation messages. Each requires different transformation logic.
  • Organization hierarchy doesn't transfer. Many target platforms (Tidio, Intercom) lack a first-class organization object. You'll need to flatten org data into contact properties or custom fields.
  • Attachment storage varies. Zammad stores attachments on the filesystem or in the database depending on config (see the storage configuration section above). Target platforms typically require base64-encoded upload or separate upload-then-attach workflows.
  • Trigger and automation configuration — workflow rules, macros, triggers, and schedulers are available via the Rails console or database but don't have first-class, documented export endpoints.
  • SLA calculation context — while SLA fields (first_response_at, close_at) exist on ticket objects, the calculation context (business hours, holiday calendars) is not exported with the ticket.
  • Audit logs — Zammad tracks some internal state changes but does not expose a comprehensive audit log via the API.
  • Permissions can hide data without errors. Ticket visibility depends on role and group access. An export can be technically successful but silently incomplete.
  • HTML body content. Article bodies are stored as HTML. If your target platform requires plain text or Markdown, you'll need an HTML parser in your transformation layer. Inline images embedded via Content-ID references require separate handling — the image binary must be extracted from attachments and re-hosted.

For a broader look at how CSV-based migrations work and where they break down, see Using CSVs for SaaS Data Migrations.

Step-by-Step: Full Zammad Data Export via API

  1. Create an API token with the permissions from the table above (ticket.agent + admin group membership + admin.user + knowledge_base.editor + admin.object_manager). Verify the token can see all groups in scope by checking total ticket count.
  2. Inventory your data — use ?only_total_count=true on /api/v1/tickets, /api/v1/users, and /api/v1/organizations to get object counts before building your extraction pipeline.
  3. Export reference data first: groups, roles, organizations, users, custom field definitions (/api/v1/object_manager_attributes), states, priorities, and tags. These serve as lookup tables when processing tickets.
  4. Export tickets in paginated batches: Walk /api/v1/tickets with ?expand=true&page=N&per_page=100. Store raw JSON Lines. Sort by id for deterministic ordering. Use the checkpoint pattern from the code sample above.
  5. Export articles per ticket: For each ticket, call /api/v1/ticket_articles/by_ticket/{id}?expand=true. No pagination needed — all articles return in one response. Write attachment metadata to a separate queue file.
  6. Download attachments asynchronously: Process the attachment queue separately via /api/v1/ticket_attachment/{ticket_id}/{article_id}/{attachment_id}. Save to {ticket_id}/{article_id}/{filename}.
  7. Export knowledge base: Use POST /api/v1/knowledge_bases/init to get KB IDs, walk category trees, and fetch each answer with translations.
  8. Run a delta pass: Re-export tickets modified since the initial extraction began using ?updated_at [gte]=TIMESTAMP. Reconcile counts against the totals from step 2.
  9. Validate: Spot-check 10–20 tickets by comparing source and exported data. Verify internal notes, attachment counts, and custom field values are all present.

When to Bring in Help

For small Zammad instances (under 5,000 tickets, minimal attachments), a competent engineer can script a full export in a day or two using the patterns above. The API is well-structured and predictable.

For larger instances — 50,000+ tickets, heavy attachment volumes, complex custom fields, multi-language knowledge bases — expect 3–5x more time on transformation than extraction. At 1 RPS with per_page=100 and per-ticket article fetches, a 50,000-ticket instance takes roughly 14 hours of API time plus attachment download time on top. Transformation to match a target platform's data model, handling edge cases (HTML-to-plain-text, inline image re-hosting, timestamp normalization), and validation are where custom engineering hours accumulate.

The recurring failure modes are predictable: under-scoped tokens, attachment URLs discovered late, custom fields missing from the first mapping pass, and system articles polluting the target platform. If you need to move off Zammad without freezing support operations, pair the export with a cutover plan like Zero-Downtime Help Desk Data Migration.

Frequently Asked Questions

Can I export full ticket history from the Zammad UI?
No. Zammad's Reporting UI can export ticket metadata to .xlsx, but it's capped at 6,000 entries and contains only flat data — no conversation threads, internal notes, or attachments. Full ticket history requires the REST API or direct database access.
What are Zammad's API rate limits?
Zammad does not publish formal API rate limits. Self-hosted instances are bounded by server resources and configurable Rack::Attack middleware. Hosted (SaaS) instances likely have implicit throttling, but specific limits are not publicly documented. Scripts should implement exponential backoff for HTTP 429 responses.
How do I download attachments from Zammad?
Attachments are tied to Articles, not Tickets. Query the ticket articles endpoint to get attachment metadata, then download each file individually via /api/v1/ticket_attachment/{ticket_id}/{article_id}/{attachment_id}. Download asynchronously — large files will block your main loop.
Why does the Zammad search API fail after 10,000 results?
Zammad's search endpoint is powered by Elasticsearch, which enforces a default max_result_window of 10,000 records. To export more than 10,000 tickets, use the standard /api/v1/tickets endpoint with page-based pagination or iterate by ticket ID instead of using search.
Can I export users and organizations from Zammad without the API?
No. Zammad's official support documentation confirms that user and organization export is only available through the REST API. The admin panel supports CSV import for users and organizations, but the reverse does not exist.

More from our Blog

Zero-Downtime Help Desk Data Migration: How to Keep Support Running During the Move
Help Desk

Zero-Downtime Help Desk Data Migration: How to Keep Support Running During the Move

This guide details the 3-stage technical process for a zero-downtime help desk migration. Learn how to use an initial bulk data transfer, a continuous delta migration (Change Data Capture), and a seamless final cutover to move platforms without any service interruption. Discover how an engineer-led approach can guarantee a 100% accurate, 50x faster migration.

Raaj Raaj · · 7 min read