Helpshift to Zammad Migration: A Technical Guide
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.
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
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)
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. For Zammad export patterns useful during parallel testing, see How to Export Data from Zammad. For a side-by-side architecture comparison, see Zammad vs Helpshift: Architecture, TCO & Migration Guide.
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 for enterprise routing or Zammad to Help Scout 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:
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:
- 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/usersandPOST /api/v1/groups. - 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 callPOST /api/v1/object_manager_attributes_execute_migrations. - Extract and load FAQs into Zammad Knowledge Base (see FAQ migration section below).
- Extract issues from Helpshift in date-windowed batches.
- Transform and load tickets with all articles, attachments, and metadata.
- Validate — Compare issue counts, message counts, and spot-check content.
- Delta sync — Re-extract issues modified since initial extraction began (see Delta Sync section).
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 |
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)
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.
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_endMessages 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:
- Metadata — automatically collected device info (OS type, app version, device model, battery level). Read-only.
- Custom Data — developer-configured key-value pairs passed via the SDK. Nested inside metadata.
- 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)
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.
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)
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):
- Create the ticket with the first article via
POST /api/v1/tickets. - 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.
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)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)
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:
- Parse the attachment URL from the Helpshift message.
- Download the file to your local staging environment.
- Base64-encode the binary content.
- Include it in the Zammad article payload.
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
passWatch 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:
- Extract FAQ sections from Helpshift via
GET /v1/{domain}/faqs→ create Zammad KB categories viaPOST /api/v1/knowledge_bases/{kb_id}/categories. - Extract individual FAQs → create Zammad KB answers via
POST /api/v1/knowledge_bases/{kb_id}/answers. - Zammad answers require
translations_attributescontainingtitle,locale, andcontent_attributes.body.
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)
Workarounds for self-hosted Zammad:
- Direct database writes: After API import, update
created_attimestamps 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:
- 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. - 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.
- 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) 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.).
# 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"}), 200Delta Processing Logic
After the initial backfill completes:
- Query
delta_eventsfor all issue IDs that received events after your extraction start timestamp. - For issues already migrated (present in
migration_log): fetch the current Helpshift issue state and update the Zammad ticket accordingly. - For new issues (not present in
migration_log): run them through the full extract → transform → load pipeline.
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.
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 connBefore 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: trueheader — or importing replay history asemailarticles instead ofnote— 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_typetotext/htmlortext/plainper article based on content detection, not a blanket default. - Authorship flattened. Missing
senderororigin_by_idon 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
- Freeze schema and state mapping before any data load.
- Prove one app or one date window end-to-end, including delta sync and notification suppression validation.
- Start webhook receiver (or begin
updated_sincepolling) at the beginning of the backfill so no changes are missed. - Backfill history in batches with source IDs and checkpoints.
- After backfill completes, process all delta events captured during the backfill window.
- Move agents and routing last — keep agents working in Helpshift until delta sync is complete.
- Audit counts, spot-check histories, verify unresolved queues, and check KB publication state before turning off Helpshift.
- 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.
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.
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.