Zendesk has a built-in export button. For small accounts doing a one-time backup, it works. For any serious data export — migration, archival, AI pipeline, or compliance — it produces a file that looks complete until you try to use it.
This guide covers all three native Zendesk export methods, what each one silently omits, and the API-level approach you need for a full extraction: tickets with comments, resolved custom field names, and Sunshine Custom Objects before the July 1, 2026 deprecation deadline.
The three native export methods
Method 1: Admin Center bulk export
Where: Admin Center > Account > Tools > Reports > Export tab
What it produces: A JSON, CSV, or XML file containing tickets, users, or organizations. Available on Suite Growth/Professional or higher.
What it includes: Ticket ID, subject, status, requester, assignee, group, timestamps, tags, priority, and custom field values.
What it does not include: Conversation history, internal notes, agent replies, side-conversations, Sunshine Custom Objects, or attachments.
Custom fields: Exported as anonymous numeric IDs. custom_field_4471, custom_field_8821. Not names.
Volume: No hard cap, but exports are processed asynchronously and delivered by email as a ZIP file. Large exports can take hours.
Method 2: View CSV export
Where: Support > Views > [select a view] > Actions > Export as CSV (or Export CSV in the upper right)
What it produces: A CSV of tickets currently in that view.
The silent cap: 1,000 tickets maximum. The export completes without warning regardless of how many tickets are in the view. If your view contains 50,000 tickets, you receive 1,000 rows and no indication that 49,000 were dropped.
What it does not include: Everything Method 1 omits, plus it is filtered to whatever view you are exporting — not your full ticket history.
Best for: Quick ad-hoc exports of small filtered sets. Not suitable for full data exports.
Method 3: Incremental Export API
Where: GET /api/v2/incremental/tickets.json?start_time=UNIX_TIMESTAMP
What it produces: Paginated ticket objects in time order, starting from any Unix timestamp. Available on any Zendesk plan. No volume cap.
What it includes: Same fields as the bulk export — ticket headers and custom field values.
What it does not include: Conversation threads, Sunshine Custom Objects.
Best for: Large-volume exports and programmatic access. This is the only method that scales to millions of tickets.
Comparison
Native export methods at a glance.
| Method | Volume cap | Ticket comments | Custom field names | Sunshine objects | Plan required |
|---|---|---|---|---|---|
| Admin Center export | None | Not included | IDs only | Not included | Suite Growth+ |
| View CSV export | 1,000 rows (silent) | Not included | IDs only | Not included | Any plan |
| Incremental Export API | None | Separate API call | Separate API call | Separate API call | Any plan |
What a complete Zendesk data export actually requires
A full export — the kind that is genuinely usable after the migration — requires four separate API pipelines running in coordination. None of them are covered by the native export button.
Step 1: Export tickets via the Incremental Export API
The Incremental Export API is the correct starting point for any full-volume Zendesk data export. It handles pagination via cursor, works on any plan, and has no ticket count limit.
import requests
import time
SUBDOMAIN = "your-subdomain"
EMAIL = "admin@yourcompany.com"
API_TOKEN = "your_api_token"
AUTH = (f"{EMAIL}/token", API_TOKEN)
def export_tickets(start_time=0):
url = f"https://{SUBDOMAIN}.zendesk.com/api/v2/incremental/tickets.json"
params = {"start_time": start_time}
tickets = []
while True:
response = requests.get(url, auth=AUTH, params=params)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
time.sleep(retry_after)
continue
response.raise_for_status()
data = response.json()
tickets.extend(data["tickets"])
if data.get("end_of_stream"):
break
params = {"start_time": data["end_time"]}
return tickets
Two important details. First, the end_of_stream flag signals completion — without checking it, the loop may run indefinitely against an empty cursor. Second, the 429 retry logic is not optional. The Incremental Export endpoint has stricter rate limits than the standard ticket API. See the full rate limit guide for the exponential backoff implementation.
Step 2: Resolve custom field names
Every ticket in the Incremental Export contains a custom_fields array like this:
{
"id": 48211,
"subject": "Refund request - order #8821",
"custom_fields": [
{ "id": 4471, "value": "billing" },
{ "id": 8821, "value": "refund_approved" },
{ "id": 11043, "value": "enterprise" }
]
}
The field IDs 4471, 8821, 11043 are meaningless without the name mapping. Fetch it once before you start the ticket export:
def get_field_mapping():
url = f"https://{SUBDOMAIN}.zendesk.com/api/v2/ticket_fields.json"
fields = {}
while url:
response = requests.get(url, auth=AUTH)
response.raise_for_status()
data = response.json()
for field in data["ticket_fields"]:
fields[field["id"]] = field["title"]
url = data.get("next_page")
return fields
def resolve_custom_fields(ticket, field_mapping):
resolved = {}
for cf in ticket.get("custom_fields", []):
name = field_mapping.get(cf["id"], f"custom_field_{cf['id']}")
resolved[name] = cf["value"]
ticket["custom_fields_resolved"] = resolved
return ticket
Run resolve_custom_fields on every ticket before writing to your output. Without this step, you export data from Zendesk that is technically complete but operationally useless — a spreadsheet full of integers where your field names should be.
Step 3: Retrieve full conversation threads
The Incremental Export returns ticket headers only. To export ticket data from Zendesk with full conversation history — replies, internal notes, and side-conversations — you need a separate call per ticket:
def get_ticket_comments(ticket_id):
url = f"https://{SUBDOMAIN}.zendesk.com/api/v2/tickets/{ticket_id}/comments.json"
comments = []
while url:
response = requests.get(url, auth=AUTH)
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", 60)))
continue
response.raise_for_status()
data = response.json()
comments.extend(data["comments"])
url = data.get("next_page")
return comments
This multiplies your API call count by the number of tickets. For 100,000 tickets, that is 100,000 additional requests. With Zendesk's rate limits, this takes time — plan for it. Internal notes are stored as public: false comments in the response and are preserved in this approach. They are not present in any native export.
Step 4: Extract Sunshine Custom Objects
This step has a hard deadline: July 1, 2026. After that date, the Sunshine Custom Objects API returns 410 Gone. The data is not archived or moved — it is permanently deleted.
If your Zendesk account uses Sunshine Custom Objects — hardware asset records, license entitlements, B2B account hierarchies, or any structured data stored outside the ticket schema — this extraction must happen before that deadline.
def export_custom_object_records(object_type_key):
url = f"https://{SUBDOMAIN}.zendesk.com/api/v2/custom_objects/{object_type_key}/records"
records = []
while url:
response = requests.get(url, auth=AUTH)
response.raise_for_status()
data = response.json()
records.extend(data.get("custom_object_records", []))
url = data.get("meta", {}).get("after_cursor")
if not data.get("meta", {}).get("has_more"):
break
return records
def list_custom_object_types():
url = f"https://{SUBDOMAIN}.zendesk.com/api/v2/custom_objects"
response = requests.get(url, auth=AUTH)
response.raise_for_status()
return response.json().get("custom_objects", [])
Start with list_custom_object_types() to discover every object type in your instance. Then export records for each type. Relationship records (the joins between object types and tickets) are stored separately under /api/v2/custom_objects/{type}/relationships and must be exported independently to preserve the relational graph.
See the full Sunshine Custom Objects export guide for the complete extraction including relationship records and the pagination edge cases.
Step 5: Resolve attachment URLs before they expire
Zendesk ticket comments include attachments as pre-signed Amazon S3 URLs. These URLs expire — typically within a few hours of generation.
A slow extraction script or a run that pauses and resumes will encounter expired URLs for attachments generated early in the process. The HTTP response is 403 Forbidden. If your script does not handle this explicitly, attachments are silently dropped.
The correct approach is to download attachment binaries during the extraction run rather than storing the S3 URLs and fetching later:
import os
def download_attachment(attachment, output_dir):
url = attachment["content_url"]
filename = attachment["file_name"]
response = requests.get(url, auth=AUTH, stream=True)
if response.status_code == 403:
print(f"Expired URL for attachment {attachment['id']}: {filename}")
return None
response.raise_for_status()
path = os.path.join(output_dir, str(attachment["id"]), filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return path
For large accounts with years of ticket history, attachment resolution is typically the most time-consuming part of the export. It cannot be parallelised without running into rate limits.
The complete Zendesk export checklist
A full export of your Zendesk data requires all five of these:
- Tickets — via the Incremental Export API with cursor-based pagination and 429 retry logic
- Custom field names — via
GET /api/v2/ticket_fields, joined to every ticket before output - Conversation threads — via
GET /api/v2/tickets/{id}/commentsfor every ticket in the export - Sunshine Custom Objects — via
GET /api/v2/custom_objectsand relationship endpoints, before July 1, 2026 - Attachments — binary files downloaded during the run, not as stored URLs
Skipping any of these produces a dataset that is technically exported but operationally incomplete. Custom field IDs without names are unusable in any destination system. Tickets without conversations are headers without history. Sunshine objects not extracted before July 1 are gone.
How long does this take to build?
Most teams underestimate by a factor of 10. The core pagination loop takes a day to write. Getting the 429 handling right takes another day. Discovering that the Incremental Export does not include conversations, then building the per-ticket comment pipeline, takes a week. Realizing that attachment URLs expire and redesigning the download approach takes another few days. Adding Sunshine Custom Object extraction, which has different pagination and a different authentication scope, takes a week.
The total is typically two to six weeks for an engineer who has not done it before — and it still misses edge cases that only appear at scale (ghost loops in the Incremental Export, cursor expiry, multi-region Zendesk accounts, soft-deleted tickets appearing in the stream).
Evicta runs all five pipelines in a single job. Human-readable column names via AI-assisted field ID mapping. Full conversation threads retrieved per ticket. Sunshine Custom Objects extracted and joined before the July 1 deadline. Attachments resolved to direct URLs during the run. Cursor-based pagination with exponential backoff throughout.
Output is a Postgres schema or AI-ready JSONL file. Flat-fee pricing: $499 for the core export (tickets, users, orgs, custom fields resolved), $1,499 for the full extraction including Sunshine Custom Objects, conversation threads, attachments, and JSONL output.
Start your extraction at Evicta before you cancel the subscription.