---
title: "How to Export Data from Zammad: Methods, API Limits & Portability"
slug: how-to-export-data-from-zammad-methods-api-limits-portability
date: 2026-08-05
author: Raaj
categories: [Help Desk, Migration Guide]
excerpt: "Learn every method to export data from Zammad — REST API extraction, database dumps, Rails console, pagination limits, and data portability gaps."
tldr: "Zammad has no bulk export UI. Use the REST API with expand=true and pagination, PostgreSQL dumps for self-hosted instances, or the Rails console for targeted exports."
canonical: https://clonepartner.com/blog/how-to-export-data-from-zammad-methods-api-limits-portability/
---

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


# 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](https://clonepartner.com/blog/blog/zammad-to-zendesk-migration-the-technical-guide/), [Zammad to Help Scout](https://clonepartner.com/blog/blog/zammad-to-help-scout-migration-a-technical-guide/), or [Zammad to Tidio](https://clonepartner.com/blog/blog/zammad-to-tidio-migration-a-technical-guide/).

## 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](https://docs.zammad.org/en/latest/api/intro.html?highlight=expand))

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.

> [!NOTE]
> 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](https://docs.zammad.org/en/latest/api/ticket/index.html))

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.

```bash
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.

```bash
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](https://docs.zammad.org/en/latest/api/intro.html?highlight=expand))

### 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:

```python
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.

```bash
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](https://docs.zammad.org/en/latest/api/ticket/articles.html))

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:

```bash
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](https://support.zammad.com/help/en-us/51-getting-started/147-can-i-import-export-users-organizations))

```bash
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**

```bash
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**

```bash
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**

```bash
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](https://github.com/MarcusHoltz/zammad-knowledge-base-export-to-static-site) 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

```bash
# 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](https://clonepartner.com/blog/blog/csv-saas-data-migration/).

### 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](https://docs.zammad.org/en/pre-release/appendix/backup-and-restore/))

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](https://support.zammad.com/help/en-us/55-storage/26-can-my-system-be-restored-to-a-previous-state))

## 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.

```bash
# Package install
zammad run rails c

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

Once inside, you can run targeted exports:

```ruby
# 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.

> [!CAUTION]
> 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](https://admin-docs.zammad.org/en/6.1/manage/report-profiles.html))
- 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](https://admin-docs.zammad.org/en/6.4/manage/report-profiles.html))

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](https://docs.zammad.org/en/latest/install/elasticsearch.html))

### 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](https://docs.zammad.org/en/latest/api/intro.html?highlight=expand))

### 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](https://clonepartner.com/blog/blog/csv-saas-data-migration/).

## 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](https://clonepartner.com/blog/blog/zero-downtime-help-desk-data-migration/).

> Planning a Zammad export or migration? Our engineers have built extraction pipelines across dozens of Zammad migrations. We handle the pagination, attachment downloading, data transformation, and validation — so your team doesn't have to debug the forty-seventh edge case at 2 AM.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## 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.
