---
title: "Helpshift to Zammad Migration: A Technical Guide"
slug: helpshift-to-zammad-migration-a-technical-guide
date: 2026-08-26
author: Rishabh Makhar
categories: [Migration Guide, Help Desk, Helpshift, Zammad]
excerpt: "Step-by-step technical guide to migrating issues, messages, and FAQs from Helpshift to Zammad via API. Covers data mapping, extraction limits, and edge cases."
tldr: "Helpshift to Zammad migration requires custom API scripting. Extract issues via Helpshift's REST API (50K pagination cap), transform to Zammad's ticket/article model, and load via Zammad's REST API."
canonical: https://clonepartner.com/blog/helpshift-to-zammad-migration-a-technical-guide
---

# Helpshift to Zammad Migration: A Technical Guide


# Helpshift to Zammad Migration: A Technical Guide

Migrating from Helpshift to Zammad means translating between a **mobile-first, issue-based** support platform and an **open-source, ticket-centric** helpdesk — entirely through custom API scripting. There is no native migration tool, no vendor-provided connector, and no built-in import path between these two systems. Zammad's official inbound migrators cover Freshdesk, Kayako, OTRS, and Zendesk, but not Helpshift. ([docs.zammad.org](https://docs.zammad.org/en/latest/migration/index.html))

Helpshift stores support conversations as **issues** containing **messages**. Zammad stores them as **tickets** containing **articles**. To preserve full conversation history — internal notes, attachments, device metadata, and authorship — you must extract from Helpshift's REST API and load through Zammad's REST API.

This guide covers the full migration architecture: data model mapping, API constraints on both sides, extraction strategies, transform logic, FAQ and knowledge base migration, delta sync implementation, resumability, and the failure modes that trip up engineering teams mid-project.

**Verified against:** Helpshift REST API v1 and Zammad 6.x/7.x REST API. Helpshift's API lifecycle is subject to change — verify endpoint behavior before scripting.

For details on getting data out of Helpshift, see [How to Export Data from Helpshift](https://clonepartner.com/blog/blog/how-to-export-data-from-helpshift-api-limits-methods-portability). For Zammad export patterns useful during parallel testing, see [How to Export Data from Zammad](https://clonepartner.com/blog/blog/how-to-export-data-from-zammad-methods-api-limits-portability). For a side-by-side architecture comparison, see [Zammad vs Helpshift: Architecture, TCO & Migration Guide](https://clonepartner.com/blog/blog/zammad-vs-helpshift-architecture-tco-migration-guide).

> [!WARNING]
> Neither Helpshift nor Zammad provides an official migration tool for this path. Every production Helpshift → Zammad migration requires custom API work. Validate attachments, authorship, and timestamp behavior against your own tenant before you lock a cutover date.

## Why Teams Move from Helpshift to Zammad

The decision drivers that appear consistently across these migrations, translated into concrete terms:

- **Data sovereignty and self-hosting.** Helpshift is a closed SaaS platform. Zammad runs self-hosted under AGPL with full infrastructure control. For teams subject to GDPR data residency requirements or SOC 2 audit controls over data location, self-hosting is often non-negotiable.
- **Cost structure shift.** Helpshift's pricing is consumption-based (per monthly active issue). Zammad's hosted plans start at €7/agent/month; self-hosting is license-free. Teams processing high issue volumes with small agent headcounts — common in mobile gaming — face a nonlinear cost disadvantage under Helpshift's model. A team with 10 agents handling 50K issues/month pays per issue under Helpshift and per agent under Zammad: the crossover point typically favors Zammad above ~5,000 issues/month at comparable agent counts.
- **Channel expansion beyond mobile.** Helpshift is purpose-built for in-app mobile support. Teams adding email-first, web form, or social channel support find Zammad's multi-channel architecture fits those workflows without bolting on separate tooling.
- **Open-source extensibility.** Zammad's codebase is on GitHub. Teams that need to customize triggers, automations, or integrations at the code level — without waiting on a vendor roadmap — gain that access.
- **Reducing SDK coupling.** Helpshift's SDK-driven approach tightly couples your mobile app to its platform at the dependency level. Zammad's REST API and open data model reduce the cost of future migrations (whether you eventually move from [Zammad to Zendesk](https://clonepartner.com/blog/blog/zammad-to-zendesk-migration-the-technical-guide) for enterprise routing or [Zammad to Help Scout](https://clonepartner.com/blog/blog/zammad-to-help-scout-migration-a-technical-guide) for a shared inbox) or integrations.

## Data Model Mapping: Helpshift → Zammad

The core translation challenge is mapping Helpshift's mobile-native, issue-centric model to Zammad's ticket/article architecture.

| Helpshift Object | Zammad Equivalent | Notes |
|---|---|---|
| **Issue** | **Ticket** | 1:1 mapping. Store the Helpshift `issue.id` in a Zammad custom field for traceability. |
| **Message** | **Article** | Each Helpshift message becomes a Zammad article on the ticket. |
| **Message origin** (`end-user` / `helpshift`) | **Article sender** (`Customer` / `Agent`) | Map explicitly on every import. Sender cannot be changed after creation. |
| **Private notes** | **Internal article** | Map to articles with `type: note` and `internal: true`. |
| **Issue state** | **Ticket state** | Requires explicit mapping — see below. |
| **Tags** | **Tags** | Direct 1:1 mapping. Add after ticket creation via the tags endpoint. |
| **Custom Issue Fields** | **Object Attributes** | Must pre-create matching custom object attributes in Zammad before import. |
| **Metadata** (device info, app version, OS) | **Custom fields or internal note** | No native equivalent in Zammad. See device metadata section. |
| **Assignee** | **Owner** | Map by agent email. Pre-create agents in Zammad first. |
| **App** | **Group** | Each Helpshift app maps to a Zammad group. Maps cleanly to Zammad's permission boundaries. |
| **FAQ / FAQ Section** | **Knowledge Base Answer / Category** | Structural match. Content format may need HTML cleanup. |
| **Attachments** | **Article Attachments** | Base64-encoded on Zammad's side. |
| **Bot flows, SDK behavior** | **No 1:1 equivalent** | Preserve the data you need, but expect to redesign the workflow. |

### Custom Issue Field Type Mapping

Helpshift CIFs are typed. Map them explicitly to Zammad object attribute types before building your schema:

| Helpshift CIF Type | Zammad Object Attribute Type |
|---|---|
| `singleline` | `input` |
| `multiline` | `textarea` |
| `dropdown` | `select` |
| `number` | `integer` |
| `date` | `date` |
| `checkbox` | `boolean` |

Create all Zammad object attributes before starting data load. Schema changes mid-import require re-running migrations and, on self-hosted instances, may require a service restart.

### State Mapping

Helpshift uses four primary issue states: `new`, `agent-replied`, `resolved`, and `rejected`. Zammad's default states are `new`, `open`, `closed`, `pending close`, and `pending reminder`. A practical mapping:

```text
Helpshift State    →  Zammad State
─────────────────────────────────────
new                →  new
agent-replied      →  open
resolved           →  closed
rejected           →  closed (with tag "rejected")
```

If you need to distinguish `resolved` from `rejected` in Zammad, create a custom state or use tags. Define this mapping before writing any load code — state mismatches are one of the most common mid-migration surprises.

## Migration Sequence

Data dependencies dictate the order. Zammad will reject ticket creation payloads if the associated customer, agent, or group does not exist. Execute your migration in this sequence:

1. **Pre-create agents and groups** — Map Helpshift assignees to Zammad agent accounts by email. Map each Helpshift app to a Zammad group. Use `POST /api/v1/users` and `POST /api/v1/groups`.
2. **Create custom object attributes** — Define Zammad custom fields for Helpshift metadata, Custom Issue Fields, and the original Helpshift issue ID. Use `POST /api/v1/object_manager_attributes`, then call `POST /api/v1/object_manager_attributes_execute_migrations`.
3. **Extract and load FAQs** into Zammad Knowledge Base (see FAQ migration section below).
4. **Extract issues from Helpshift** in date-windowed batches.
5. **Transform and load tickets** with all articles, attachments, and metadata.
6. **Validate** — Compare issue counts, message counts, and spot-check content.
7. **Delta sync** — Re-extract issues modified since initial extraction began (see Delta Sync section).

> [!WARNING]
> After creating or modifying custom object attributes in Zammad, you **must** call the execute migrations endpoint. Until you do, the new fields will not be usable on tickets. Object Manager changes trigger migrations and may require a restart on self-hosted instances. Freeze the schema before starting data load.

## Extracting Data from Helpshift

### Issues and Messages

Helpshift's REST API exposes issues at `GET /v1/{domain}/issues`. Key constraints:

| Constraint | Value |
|---|---|
| Default page size | 100 issues |
| Max page size | 1,000 issues |
| Max `page × page-size` | 50,000 |
| Auth method | HTTP Basic (API key as username, empty password) |
| Messages | Inline in issue response (no separate endpoint) |
| Rate limits | Not publicly documented — 429 responses observed at sustained high request rates |

> [!NOTE]
> Helpshift's pagination hard-caps at 50,000 issues per query window. For accounts with more than 50K issues, you **must** partition extraction using the `created_since` date filter. Failing to do this means you'll silently miss data past page 50.

Helpshift recommends an oldest-first export pattern using `created_since`, `created_until`, and `sort-by=creation-time`. This is the right approach because realtime changes can make paged reads inaccurate during iteration. ([developers.helpshift.com](https://developers.helpshift.com/rest-api/scenarios/))

**On Helpshift rate limits:** The limits are not published. In practice, sustained extraction at high request frequencies triggers 429 responses. Implement exponential backoff starting at 2 seconds, doubling up to a 60-second ceiling, with ±20% jitter. Use 30-day time windows as your default partition — most accounts' busiest months stay under 50K issues at that window size. If a window exceeds 50K, halve the window and re-extract.

```python
import requests
import time
import random

DOMAIN = "your-domain"
API_KEY = "your-api-key"
BASE_URL = f"https://api.helpshift.com/v1/{DOMAIN}/issues"

def fetch_issues_with_retry(created_since, created_until=None, page=1, page_size=1000, max_retries=5):
    params = {
        "page": page,
        "page-size": page_size,
        "sort-by": "creation-time",
        "sort-order": "asc",
        "created_since": created_since,
    }
    if created_until:
        params["created_until"] = created_until

    for attempt in range(max_retries):
        try:
            resp = requests.get(BASE_URL, params=params, auth=(API_KEY, ""), timeout=30)
            if resp.status_code == 429:
                wait = min(2 ** attempt + random.uniform(0, 0.4 * (2 ** attempt)), 60)
                time.sleep(wait)
                continue
            resp.raise_for_status()
            return resp.json()
        except requests.exceptions.RequestException as e:
            if attempt == max_retries - 1:
                raise
            wait = min(2 ** attempt + random.uniform(0, 0.4 * (2 ** attempt)), 60)
            time.sleep(wait)

def extract_window(created_since, created_until):
    """Extract all issues in a time window, using cursor-based pagination."""
    page = 1
    while True:
        data = fetch_issues_with_retry(created_since, created_until, page=page)
        issues = data.get("issues", [])
        if not issues:
            break
        yield from issues
        total_pages = data.get("total-pages", 1)
        if page >= total_pages:
            break
        page += 1

# Partition into 30-day windows to stay under the 50K ceiling
def extract_all_issues(start_ts, end_ts, window_days=30):
    window_seconds = window_days * 86400
    cursor = start_ts
    while cursor < end_ts:
        window_end = min(cursor + window_seconds, end_ts)
        for issue in extract_window(cursor, window_end):
            yield issue
        cursor = window_end
```

Messages are included inline within the issue response — each issue object contains a `messages` array with `body`, `author`, `origin`, and `attachment` fields.

### Custom Issue Fields and Metadata

Helpshift separates three types of issue-level data:

1. **Metadata** — automatically collected device info (OS type, app version, device model, battery level). Read-only.
2. **Custom Data** — developer-configured key-value pairs passed via the SDK. Nested inside metadata.
3. **Custom Issue Fields** — structured fields with types (singleline, multiline, dropdown, number, date, checkbox) configured in the dashboard.

All three are included in the issue API response. Plan your Zammad object attribute schema before extraction so you know what to capture.

### User Identity

Helpshift issues filed through the mobile SDK often identify users by device ID, not email. For richer identity data, Helpshift's User Hub Bulk APIs run asynchronously and accept up to 10,000 records per request. Use them to retrieve email, phone, platform IDs, language, or LTV data you need in Zammad. ([developers.helpshift.com](https://developers.helpshift.com/rest-api/user-hub-bulk-apis/))

Pick a canonical identity key early — email, device ID, or player ID — and apply it consistently across all records. Switching the key mid-import produces duplicate customers in Zammad that are difficult to merge after the fact.

## Loading Data into Zammad

### Authentication and Notification Suppression

Use token auth for the migration. Zammad supports basic auth, token auth, and OAuth2, but recommends access tokens for automation. The API user must have `ticket.agent` permission.

> [!CAUTION]
> **Suppress notifications on every write.** Send the `X-Zammad-Suppress-Notifications: true` header on all ticket create/update and article creation requests. Without this, your migration will blast customers and agents with notifications for every historical ticket. ([docs.zammad.org](https://docs.zammad.org/en/pre-release/api/ticket/))

### Zammad Write Throughput and Elasticsearch Lag

On a self-hosted Zammad instance with 4 CPU cores and 16 GB RAM, Elasticsearch indexing typically keeps pace with API writes up to approximately 2–3 articles per second sustained. Above that rate, the indexing queue falls behind — you'll observe Elasticsearch returning stale or missing results while the import is running. Do not use Zammad's search API to verify data during import; use direct REST API GET endpoints or database queries instead.

Practical guidance:
- Batch ticket creation in groups of 50–100 with a 1–2 second pause between batches.
- For migrations above 50K tickets, plan for Elasticsearch catch-up time after the bulk load completes before declaring the migration done.
- Monitor the indexing queue via Zammad's admin panel or directly via the Elasticsearch API (`GET /_cat/indices?v`).

### Ticket and Article Creation

Zammad's REST API requires a two-step process for tickets with multiple messages (a structural requirement you'll also encounter in other inbound API projects like an [Ada to Zammad migration](https://clonepartner.com/blog/blog/ada-to-zammad-migration-the-technical-guide)):

1. **Create the ticket** with the first article via `POST /api/v1/tickets`.
2. **Add remaining articles** one at a time via `POST /api/v1/ticket_articles`.

You cannot create a ticket with multiple articles in a single API call.

```python
import requests
import json
import time
import random

ZAMMAD_URL = "https://your-zammad.example.com/api/v1"
HEADERS = {
    "Authorization": "Token token=YOUR_TOKEN",
    "Content-Type": "application/json",
    "X-Zammad-Suppress-Notifications": "true"
}

def zammad_post_with_retry(endpoint, payload, max_retries=5):
    for attempt in range(max_retries):
        try:
            resp = requests.post(
                f"{ZAMMAD_URL}/{endpoint}",
                headers=HEADERS,
                data=json.dumps(payload),
                timeout=30
            )
            if resp.status_code in (429, 502, 503, 504):
                wait = min(2 ** attempt + random.uniform(0, 0.4 * (2 ** attempt)), 60)
                time.sleep(wait)
                continue
            resp.raise_for_status()
            return resp.json()
        except requests.exceptions.RequestException as e:
            if attempt == max_retries - 1:
                raise
            wait = min(2 ** attempt + random.uniform(0, 0.4 * (2 ** attempt)), 60)
            time.sleep(wait)

def map_state(helpshift_state):
    return {
        "new": "new",
        "agent-replied": "open",
        "resolved": "closed",
        "rejected": "closed",
    }.get(helpshift_state, "open")

def map_sender(origin):
    return "Customer" if origin == "end-user" else "Agent"

def create_ticket(issue, first_message, group_name):
    payload = {
        "title": issue.get("title", f"Helpshift Issue {issue['id']}"),
        "group": group_name,
        "customer_id": f"guess:{first_message['author'].get('email', f\"{issue['id']}@helpshift-import.local\")}",
        "state": map_state(issue["state_data"]["state"]),
        "hs_issue_id": issue["id"],  # custom object attribute
        "article": {
            "subject": issue.get("title", "Migrated from Helpshift"),
            "body": first_message["body"],
            "content_type": "text/html" if "<" in first_message["body"] else "text/plain",
            "type": "note",
            "internal": False,
            "sender": map_sender(first_message.get("origin", "end-user"))
        }
    }
    return zammad_post_with_retry("tickets", payload)

def add_article(ticket_id, message):
    payload = {
        "ticket_id": ticket_id,
        "body": message["body"],
        "content_type": "text/html" if "<" in message["body"] else "text/plain",
        "type": "note",
        "internal": message.get("is_private", False),
        "sender": map_sender(message.get("origin", "end-user"))
    }
    return zammad_post_with_retry("ticket_articles", payload)
```

> [!TIP]
> Use Zammad's `guess:{email}` syntax for `customer_id` to auto-create customer records during import. This saves a separate user-creation pass and avoids ID lookup calls.

### Article Type: `note` vs `email`

Choose article types deliberately. `note` is non-communicating — safe for history replay. `email` carries channel semantics but can trigger side effects. Zammad explicitly warns that `internal: true` does not by itself stop email sending. Default to `note` for migrated history unless mail semantics are a hard requirement. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/ticket/articles.html))

### Creating Articles on Behalf of Users

Zammad supports creating tickets on behalf of other users — essential for preserving the original customer identity. Use `customer_id` with `guess:{email}` on ticket creation, and `origin_by_id` on article creation to attribute messages to the correct agent or customer.

### Handling Helpshift Users Without Email

Zammad requires an email address for customer records. Helpshift issues filed through the mobile SDK often identify users by device ID only. Options:

- **Generate synthetic emails:** `{helpshift_user_id}@helpshift-import.local` — creates individual customer records you can later merge or update when real addresses are available. This is the cleanest approach.
- **Map to a catch-all account** — loses individual user tracking.
- **Use any email from Helpshift's user profile** if available via User Hub Bulk APIs.

## Handling Attachments

Helpshift provides attachment URLs in message payloads. Zammad accepts attachments as base64-encoded data in article creation requests. The process:

1. Parse the attachment URL from the Helpshift message.
2. Download the file to your local staging environment.
3. Base64-encode the binary content.
4. Include it in the Zammad article payload.

```python
import base64
import mimetypes
import requests

def download_and_encode_attachment(url, api_key):
    resp = requests.get(url, auth=(api_key, ""), timeout=60)
    resp.raise_for_status()
    encoded = base64.b64encode(resp.content).decode("utf-8")
    content_type = resp.headers.get("Content-Type", "application/octet-stream")
    return encoded, content_type

def add_article_with_attachments(ticket_id, message, api_key):
    attachments = []
    for att in message.get("attachments", []):
        try:
            encoded_data, mime_type = download_and_encode_attachment(att["url"], api_key)
            # Skip attachments over 20MB after encoding (base64 adds ~33%)
            if len(encoded_data) > 20 * 1024 * 1024 * 4 // 3:
                log_skipped_attachment(ticket_id, att)
                continue
            attachments.append({
                "filename": att.get("file_name", "attachment"),
                "data": encoded_data,
                "mime-type": mime_type
            })
        except Exception as e:
            log_skipped_attachment(ticket_id, att, error=str(e))

    payload = {
        "ticket_id": ticket_id,
        "body": message["body"],
        "content_type": "text/html" if "<" in message["body"] else "text/plain",
        "type": "note",
        "internal": message.get("is_private", False),
        "sender": map_sender(message.get("origin", "end-user")),
        "attachments": attachments
    }
    return zammad_post_with_retry("ticket_articles", payload)

def log_skipped_attachment(ticket_id, att, error=None):
    # Write to staging DB for manual review
    pass
```

> [!CAUTION]
> **Watch for payload size.** Base64 encoding inflates file size by ~33%. Helpshift is heavily used in mobile gaming and app support, where screenshot and screen recording attachments are common. A single issue might carry dozens of image attachments. If your Zammad instance is self-hosted, adjust `client_max_body_size` in Nginx or `LimitRequestBody` in Apache to allow large payloads. Log all skipped attachments to a staging table for manual review rather than silently dropping them.

## Migrating Helpshift FAQs to Zammad Knowledge Base

Helpshift organizes help content as **FAQs** grouped into **FAQ Sections**. Zammad's Knowledge Base uses **Answers** grouped into **Categories** — a direct structural match.

The migration flow:

1. Extract FAQ sections from Helpshift via `GET /v1/{domain}/faqs` → create Zammad KB categories via `POST /api/v1/knowledge_bases/{kb_id}/categories`.
2. Extract individual FAQs → create Zammad KB answers via `POST /api/v1/knowledge_bases/{kb_id}/answers`.
3. Zammad answers require `translations_attributes` containing `title`, `locale`, and `content_attributes.body`.

```python
def create_kb_answer(kb_id, category_id, faq, locale="en-us"):
    """
    Create a Zammad Knowledge Base answer from a Helpshift FAQ.
    translations_attributes holds one entry per locale.
    content_attributes.body accepts HTML.
    """
    import html
    clean_body = sanitize_html(faq.get("body", ""))  # strip SDK-injected elements

    payload = {
        "category_id": category_id,
        "translations_attributes": [
            {
                "title": faq.get("title", "Untitled"),
                "locale": locale,
                "content_attributes": {
                    "body": clean_body
                }
            }
        ]
    }
    resp = requests.post(
        f"{ZAMMAD_URL}/knowledge_bases/{kb_id}/answers",
        headers=HEADERS,
        data=json.dumps(payload),
        timeout=30
    )
    resp.raise_for_status()
    return resp.json()

def create_kb_category(kb_id, section_name, locale="en-us", parent_id=None):
    payload = {
        "translations_attributes": [
            {"title": section_name, "locale": locale}
        ]
    }
    if parent_id:
        payload["parent_id"] = parent_id
    resp = requests.post(
        f"{ZAMMAD_URL}/knowledge_bases/{kb_id}/categories",
        headers=HEADERS,
        data=json.dumps(payload),
        timeout=30
    )
    resp.raise_for_status()
    return resp.json()
```

If your Helpshift FAQs are multi-language, create a `translations_attributes` entry for each locale. Watch for HTML formatting differences — Helpshift FAQ bodies may include mobile-specific markup or SDK-injected elements that render poorly in Zammad's knowledge base. Run content through an HTML sanitizer before import.

This is not a drop-in replacement for Helpshift bot trees or SDK-driven self-service. Those workflows need to be redesigned in Zammad.

## Timestamp Preservation

One of the hardest problems in any helpdesk migration. Zammad's API sets `created_at` and `updated_at` server-side when creating tickets and articles. The standard API does not document these as accepted fields in create payloads — `created_at` appears in API responses but not as a writable input. ([docs.zammad.org](https://docs.zammad.org/en/pre-release/api/ticket/))

Workarounds for self-hosted Zammad:

- **Direct database writes:** After API import, update `created_at` timestamps directly in PostgreSQL/MySQL. Risk: this bypasses Zammad's business logic and Elasticsearch indexing. You must re-index after updating timestamps.
- **Rails console:** Use Zammad's Rails console to update timestamps with model callbacks: `Ticket.find(id).update_columns(created_at: original_ts)` followed by a manual re-index call.

For hosted Zammad instances, you have no database access. Your only option is to store original Helpshift timestamps in custom fields (`hs_created_at`, `hs_updated_at`) and accept that Zammad's native timestamps reflect the migration date.

If exact historical timestamps are a hard requirement, test the workaround against your specific Zammad version early and plan for it before you start loading data.

## Handling Helpshift Device Metadata

Helpshift automatically captures device-level metadata through its SDK: OS version, device model, app version, screen resolution, carrier info, battery level, and more. Zammad has no native equivalent.

Three options:

1. **Custom object attributes on the ticket.** Create dedicated fields in Zammad for key metadata like `device_model`, `os_version`, `app_version`. Best if you actively query or filter on this data post-migration.
2. **Structured note as the first internal article.** Dump the full metadata JSON as an internal article on each ticket. Searchable via Elasticsearch but not filterable as a field.
3. **Drop it.** If you don't use device metadata for reporting or routing post-migration, don't carry it over.

Option 1 is the most operationally useful. Option 2 is the cheapest to implement. Be honest about which metadata fields you actually use before over-engineering the schema — teams routinely design for Option 1 and discover post-migration that they never filter on device fields.

## Delta Sync: Capturing Changes During Migration

The gap between "extract everything" and "handle changes during cutover" is where migrations actually fail. A backfill of large datasets takes days. During that time, Helpshift issues continue to be created and updated. Your cutover plan must account for this.

### Webhook-Driven Delta Capture

Helpshift supports webhooks for real-time event delivery. ([developers.helpshift.com](https://developers.helpshift.com/rest-api/getting-started/)) Configure a webhook receiver at the start of your backfill to capture all changes during the extraction window. The webhook payload includes the full issue object and a `meta.action` field indicating the event type (`issue-created`, `issue-replied`, `issue-resolved`, etc.).

```python
# Minimal Flask webhook receiver for delta capture during migration
from flask import Flask, request, jsonify
import sqlite3
import json
import hmac
import hashlib

app = Flask(__name__)
WEBHOOK_SECRET = "your-helpshift-webhook-secret"

def verify_signature(payload_bytes, signature_header):
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        payload_bytes,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header or "")

@app.route("/helpshift-webhook", methods=["POST"])
def receive_webhook():
    if not verify_signature(request.data, request.headers.get("X-Helpshift-Hmac-Sha256")):
        return jsonify({"error": "invalid signature"}), 401

    event = request.json
    issue_id = event.get("issue", {}).get("id")
    action = event.get("meta", {}).get("action")

    conn = sqlite3.connect("migration_progress.db")
    conn.execute("""
        INSERT OR REPLACE INTO delta_events (helpshift_issue_id, action, payload, received_at)
        VALUES (?, ?, ?, CURRENT_TIMESTAMP)
    """, (issue_id, action, json.dumps(event)))
    conn.commit()
    conn.close()
    return jsonify({"status": "ok"}), 200
```

### Delta Processing Logic

After the initial backfill completes:

1. Query `delta_events` for all issue IDs that received events after your extraction start timestamp.
2. For issues already migrated (present in `migration_log`): fetch the current Helpshift issue state and update the Zammad ticket accordingly.
3. For new issues (not present in `migration_log`): run them through the full extract → transform → load pipeline.

```python
def process_delta_events(conn):
    cursor = conn.execute("""
        SELECT DISTINCT helpshift_issue_id FROM delta_events
        WHERE received_at > (SELECT MIN(created_at) FROM migration_log)
    """)
    issue_ids = [row[0] for row in cursor.fetchall()]

    for issue_id in issue_ids:
        migrated = conn.execute(
            "SELECT zammad_ticket_id FROM migration_log WHERE helpshift_issue_id = ? AND status = 'done'",
            (issue_id,)
        ).fetchone()

        # Fetch current state from Helpshift
        issue = fetch_issue_by_id(issue_id)

        if migrated:
            # Update existing Zammad ticket
            zammad_ticket_id = migrated[0]
            sync_ticket_updates(zammad_ticket_id, issue)
        else:
            # New issue created during backfill
            load_issue_to_zammad(issue)
```

### API-Based Delta as Fallback

If webhooks are not available on your Helpshift plan, use the `updated_since` filter on the issues endpoint: `GET /v1/{domain}/issues?updated_since={backfill_start_timestamp}`. This returns all issues modified since that timestamp. Run this after your initial backfill completes and process the results the same way.

## API Rate Limits

### Helpshift

Helpshift's rate limits are not publicly documented. Sustained high-frequency extraction triggers 429 responses. Implement exponential backoff starting at 2 seconds, doubling per retry, with ±20% jitter, capping at 60 seconds. The 50K pagination ceiling is the larger structural constraint — partition by 30-day `created_since` windows to work around it. If any 30-day window approaches 50K issues, halve the window.

### Zammad

Self-hosted Zammad instances have no built-in rate limiter by default, but database and Elasticsearch bottlenecks become the effective ceiling. At sustained rates above ~2–3 articles/second on typical hardware, Elasticsearch indexing falls behind. Batch writes in groups of 50–100 with 1–2 second pauses. On Zammad's SaaS offering, you are subject to their infrastructure limits.

## Building for Resumability

Migrations of significant size will fail partway through. Network errors, API timeouts, and rate limit hits are inevitable. Your script must be resumable and idempotent.

Track progress in a local database: map each Helpshift `issue_id` to its Zammad `ticket_id` and a status flag.

```python
import sqlite3

def init_tracking_db():
    conn = sqlite3.connect("migration_progress.db")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS migration_log (
            helpshift_issue_id TEXT PRIMARY KEY,
            zammad_ticket_id INTEGER,
            status TEXT DEFAULT 'pending',
            error_message TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS delta_events (
            helpshift_issue_id TEXT,
            action TEXT,
            payload TEXT,
            received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (helpshift_issue_id, received_at)
        )
    """)
    conn.commit()
    return conn
```

Before creating a ticket in Zammad, check if that Helpshift issue ID already has a Zammad ticket in your tracking table. Also store the original Helpshift issue ID in a `hs_issue_id` custom field on the Zammad ticket as a secondary check — resume logic based only on local checkpoint files tends to fail when workers are restarted or batches are retried.

Store extracted Helpshift data in a local staging database (SQLite or PostgreSQL) before loading into Zammad. Do not pipe data directly from one API to the other in memory. The staging layer gives you resumability, allows re-runs, and makes debugging transform issues far easier.

## Edge Cases and Failure Modes

- **Orphaned agent references.** Helpshift issues often reference agents who left the company. Attempting to assign a Zammad ticket to a non-existent agent causes the API call to fail. Maintain a fallback mapping: collect all Helpshift assignee IDs, pre-create any that don't exist in Zammad, or map orphaned assignments to a designated "Legacy Agent" account.
- **Notifications leaked.** Forgetting the `X-Zammad-Suppress-Notifications: true` header — or importing replay history as `email` articles instead of `note` — blasts customers and agents with notifications for years-old tickets. Test notification suppression on a single ticket before running any batch.
- **HTML/plain text mismatch.** Helpshift messages from the mobile SDK may be plain text; agent responses may be HTML. Set `content_type` to `text/html` or `text/plain` per article based on content detection, not a blanket default.
- **Authorship flattened.** Missing `sender` or `origin_by_id` on articles makes conversation history unreadable. Every article needs explicit sender attribution.
- **Custom fields changed mid-import.** Object Manager changes in Zammad trigger migrations and may require a restart on self-hosted instances. All schema changes must be complete and migrations executed before data load begins.
- **Elasticsearch lag.** Pushing hundreds of thousands of tickets via API at high throughput causes indexing lag. Don't rely on Zammad's search to verify data during import; use the REST API GET endpoints directly.
- **Identity never normalized.** Pick one canonical identity key (email, device ID, player ID) before extraction and apply it everywhere. Inconsistency produces duplicate Zammad customers that require manual merging after the fact.
- **Attachment URL expiry.** Helpshift attachment URLs in historic issues may be time-limited. Download attachments during extraction and stage them locally — don't store URLs expecting to fetch them later during the load phase.

## Time and Effort Estimation

Rough benchmarks based on typical migration projects:

| Volume | Estimated Effort | Notes |
|---|---|---|
| < 5,000 issues | 2–4 days | Single engineer, straightforward script |
| 5,000–50,000 issues | 1–2 weeks | Need date-windowed extraction, resumability |
| 50,000–500,000 issues | 2–4 weeks | Partition strategy, parallel extraction, attachment handling |
| 500,000+ issues | 4–8 weeks | Full pipeline with monitoring, validation, delta sync |

These estimates assume one engineer with API scripting experience. Add time for custom field schema design, stakeholder validation, and the inevitable mid-migration discoveries.

The biggest variable is attachment volume. A 50K-issue migration with minimal attachments can complete the load phase in 2–3 days at ~2 articles/second. The same 50K issues with heavy screenshot and screen recording attachments — typical in mobile gaming support — can take weeks due to download/upload throughput and base64 payload size. Estimate attachment volume before scoping.

## Validation Checklist

After migration, verify:

- [ ] **Issue count match** — total issues extracted from Helpshift = total tickets in Zammad.
- [ ] **Message count match** — total messages per issue = total articles per ticket.
- [ ] **Attachment integrity** — spot-check file sizes and content; verify nothing was silently skipped.
- [ ] **State mapping** — verify resolved/rejected issues are closed in Zammad.
- [ ] **Agent assignment** — verify tickets are assigned to the correct Zammad agents.
- [ ] **Custom field data** — spot-check CIF values transferred to Zammad object attributes.
- [ ] **Knowledge base content** — verify FAQ section → category mapping and article body rendering.
- [ ] **Search functionality** — confirm Elasticsearch has indexed all imported data. Run sample searches against known ticket content.
- [ ] **Delta events processed** — confirm all webhook-captured or API-polled changes during backfill have been applied.
- [ ] **Notification suppression** — verify no emails were sent to customers during the import by checking your email sending logs.

## When Not to Migrate Everything

Not every Helpshift issue is worth moving. Consider:

- **Resolved issues older than 12–24 months** — are they ever referenced? If not, archive to CSV and skip.
- **Bot-only conversations** — Helpshift's automation can generate high volumes of auto-resolved issues with zero human interaction. These rarely have value in Zammad.
- **Rejected/spam issues** — unless you need them for audit, leave them behind.

A selective migration runs faster, costs less, and produces a cleaner Zammad instance. Establish a cutoff date and data inclusion criteria with your support team before writing extraction logic.

## A Cutover Plan That Keeps Support Running

1. Freeze schema and state mapping before any data load.
2. Prove one app or one date window end-to-end, including delta sync and notification suppression validation.
3. **Start webhook receiver** (or begin `updated_since` polling) at the beginning of the backfill so no changes are missed.
4. Backfill history in batches with source IDs and checkpoints.
5. After backfill completes, process all delta events captured during the backfill window.
6. Move agents and routing last — keep agents working in Helpshift until delta sync is complete.
7. Audit counts, spot-check histories, verify unresolved queues, and check KB publication state before turning off Helpshift.
8. Keep Helpshift in read-only access for 2–4 weeks post-cutover as a fallback reference.

For a deeper look at running migrations without downtime, see [Zero-Downtime Help Desk Data Migration](https://clonepartner.com/blog/blog/zero-downtime-help-desk-data-migration).

## What a Good Migration Looks Like

A successful Helpshift to Zammad migration preserves usable history, correct identity, searchable metadata, and a target workflow your team will actually use. That means conservative article types, aggressive source-side validation, explicit handling of every Helpshift feature that has no direct Zammad equivalent, and a delta sync strategy that closes the gap between backfill start and cutover.

The parts teams consistently underestimate: authorship fidelity, attachment download throughput, resumability when failures occur mid-batch, and the delta window between backfill start and cutover date. These are engineering problems with known solutions — the key is planning for them before you start loading data.

> Need help migrating from Helpshift to Zammad? We'll scope your migration, map your data model, and handle the entire pipeline — extraction, transform, load, and delta sync. Book a 30-minute call to get started.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Is there a native migration tool from Helpshift to Zammad?

No. Neither platform provides a built-in migration tool for this path. Zammad's official inbound migrators cover Freshdesk, Kayako, OTRS, and Zendesk, but not Helpshift. Every production migration requires custom API scripting.

### How do Helpshift issues map to Zammad tickets?

Helpshift issues map 1:1 to Zammad tickets. Helpshift messages become Zammad articles. Custom Issue Fields map to Zammad object attributes. Helpshift FAQ sections map to Zammad Knowledge Base categories, and individual FAQs map to Knowledge Base answers.

### What is the biggest limitation when extracting data from Helpshift's API?

Helpshift's pagination hard-caps at 50,000 issues per query window (page × page-size ≤ 50,000). For accounts with more than 50K issues, you must partition extraction using the created_since date filter to slide through time windows.

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

Not through the standard API — Zammad sets created_at and updated_at server-side. On self-hosted instances, you can update timestamps via direct database writes or the Rails console after import. On hosted instances, store original timestamps in custom fields.

### How do Helpshift private notes map into Zammad?

Map them to Zammad articles with type: note and internal: true. This preserves them as agent-only history instead of customer-visible messages.
