---
title: "Zendesk to Plain Migration: The Complete Technical Guide"
slug: zendesk-to-plain-migration-the-complete-technical-guide
date: 2026-07-28
author: Rishabh
categories: [Migration Guide, Zendesk]
excerpt: "A technical guide to migrating from Zendesk to Plain. Covers Plain's native importer, API-based migration, GraphQL object mapping, rate limits, and edge cases."
tldr: "Plain's native Zendesk importer handles standard data (users, tickets, messages, tags). Custom fields, organizations, and attachments need API scripting or a managed service."
canonical: https://clonepartner.com/blog/zendesk-to-plain-migration-the-complete-technical-guide/
---

# Zendesk to Plain Migration: The Complete Technical Guide


# Zendesk to Plain Migration: The Complete Technical Guide

Migrating from Zendesk to Plain means moving from a broad, enterprise-grade helpdesk to an API-first, GraphQL-native support platform built for B2B engineering teams. The fastest path is Plain's built-in Zendesk importer, which handles end users, tickets, messages, and tags automatically. But if your scope includes custom field mapping, organization-level data, attachment preservation, knowledge base content, or granular control over the migration, you'll need to supplement with API scripting or a managed service.

This guide covers every viable approach, the data model translation, API constraints on both sides, and the edge cases that break most migrations.

> **Disclosure:** This guide is published by ClonePartner, a company that offers managed migration services. We have a commercial interest in one of the approaches described below. We've aimed to provide technically accurate guidance regardless of which path you choose.

## Why Teams Move from Zendesk to Plain

The migration pattern we see most is technical B2B companies — developer tools, infrastructure, API platforms — that have outgrown Zendesk's UI-first model and want deeper programmatic control over their support stack.

Common drivers:

- **API-first architecture.** Plain exposes a fully public GraphQL API where every object is readable and writable. Zendesk's REST API has grown organically but carries legacy constraints and tighter rate limits on lower-tier plans (200 req/min on Team vs. 2,500 on Enterprise Plus).
- **Engineering-team collaboration.** Plain integrates natively with Linear, GitHub, and Jira for escalation workflows. Zendesk can do this through Marketplace apps, but the plumbing is heavier.
- **Bring Your Own AI agent.** Plain's architecture lets you connect Claude, GPT, Gemini, or custom models directly via Machine Users — no per-resolution fees.
- **Cost structure.** Plain includes AI on every plan. Zendesk's per-agent cost scales faster, especially once you add the High Volume API add-on ($35/agent/month on Suite plans) or Advanced AI features.

Plain is purpose-built for B2B technical support. If you're running high-volume B2C, e-commerce, or multi-brand support with complex routing, Zendesk's breadth is likely still a better fit. Know what you're trading before you start.

## Core Data Model Differences

Before writing any migration code, understand the structural mismatch. These two platforms model support data differently.

| Concept | Zendesk | Plain |
|---|---|---|
| Support request | Ticket | Thread |
| Status values | New, Open, Pending, On-hold, Solved, Closed | Todo, Done, Snoozed |
| End customer | User (end-user role) | Customer |
| Company/account | Organization | Company (auto-set by email domain) |
| Customer grouping | Organization memberships | Tenants (maps to your product's workspace/org concept) |
| Categorization | Tags + custom fields | Labels + Thread Fields |
| Agent | User (agent/admin role) | User |
| Internal comments | Internal notes on tickets | Notes on threads |
| Custom attributes | Custom ticket/user/org fields | Thread Fields (schema-configured) |
| Macros | Macros | Workflows / Rules |
| Automation | Triggers + Automations | Auto-responders + Rule engine |
| Knowledge base | Zendesk Guide | Help Center |
| SLA tracking | SLA Policies | SLAs (configurable per tier) |

The biggest structural gap: **Zendesk Organizations don't have a direct 1:1 import path into Plain.** Plain auto-assigns Companies based on email domain, and Tenants serve as the primary organizational grouping. A customer can belong to one Company but can be added to multiple Tenants. Use **Company** for the business identity. Use **Tenant** when you need to model your product's workspace, org, or billing boundary. ([plain.com](https://www.plain.com/docs/graphql/companies))

If your Zendesk setup relies heavily on organization-level custom fields, membership rules, or shared organization tickets, you'll need to plan how that context maps to Plain's Company + Tenant model before writing any migration code.

## Migration Approaches

There are six viable paths. Each carries different trade-offs for scope, control, and engineering effort.

### 1. Plain's Native Zendesk Importer

Plain ships a built-in Zendesk importer accessible from **Settings → Zendesk importer**. ([help.plain.com](https://help.plain.com/article/zendesk))

**How it works:**
1. Enter your Zendesk subdomain and authenticate.
2. Plain imports all end users as Customers, all tickets as Threads, all messages (including internal notes) as notes, and all ticket tags as Labels.
3. Closed/Solved tickets become DONE threads; everything else becomes TODO.
4. After the initial import, new end users, tickets, messages, and tags created in Zendesk are auto-synced into Plain.

**When to use it:** You want the fastest possible migration, your data needs are standard (users, tickets, conversation history, tags), and you don't need granular control over field mapping.

**Pros:**
- Zero engineering effort
- Includes ongoing sync for new records after initial import
- Imported data won't trigger webhooks or auto-responders (safe to run in production)

**Cons:**
- No custom field mapping — Zendesk custom ticket fields don't carry over as Thread Fields
- Organizations are not imported as Companies or Tenants
- Status mapping is coarse (only TODO vs. DONE)
- Changes to existing Zendesk tickets after the initial import (status, priority, assignee) do not keep syncing ([help.plain.com](https://help.plain.com/article/zendesk))
- No granular filtering — it's all-or-nothing
- Available on Plain's Horizon and Frontier plans

**Complexity:** Low
**Scalability:** Good for small to mid-size accounts. Large accounts (500K+ tickets) may take hours.

### 2. API-Based Migration (Zendesk REST → Plain GraphQL)

Build a custom script that extracts from Zendesk's REST API and loads into Plain's GraphQL API.

**How it works:**
1. Extract users, organizations, tickets, and comments from Zendesk using incremental export endpoints.
2. Transform data: map statuses, flatten organization data into Tenant/Company structures, convert custom fields to Thread Field schemas.
3. Load into Plain using GraphQL mutations: `upsertCustomer`, `importThread`, `importThreadMessages`, and the attachment upload flow. ([plain.com](https://www.plain.com/docs/graphql/threads/import))
4. Rebuild relationships: link Customers to Companies, assign Tenants, apply Labels.

**When to use it:** You need custom field mapping, organization migration, attachment handling, selective data filtering, or historical timestamp preservation.

**Pros:**
- Full control over every record and transformation
- Preserves timestamps, author attribution, and attachments
- Idempotent re-runs via `externalId` — safe to resume after failures
- Imported threads don't trigger SLAs or auto-responders

**Cons:**
- Significant engineering effort (see [Timeline Estimates](#timeline-estimates) below for sizing variables)
- Must handle rate limits on both sides
- Error handling, retries, and relationship sequencing are your responsibility
- Attachment re-upload logic is non-trivial

**Complexity:** High
**Scalability:** Excellent with proper batching and cursor-based pagination.

### 3. CSV Export → Transform → API Import

A hybrid approach: export from Zendesk using its native CSV/JSON export, transform offline, then load via Plain's GraphQL API.

**How it works:**
1. Enable data exports in Zendesk (account owner may need to contact Zendesk support to activate this).
2. Export tickets, users, and organizations as JSON or CSV from **Admin Center → Account → Tools → Reports → Export**.
3. Transform the exported files: clean data, map fields, restructure for Plain's model.
4. Load into Plain using GraphQL mutations or the TypeScript SDK.

**When to use it:** Your Zendesk plan doesn't support high API rate limits but does support bulk export, or you want an offline transformation step for auditing.

**Pros:**
- Avoids Zendesk API rate limits during extraction
- Transformation can be done offline with any tool (Python, SQL, spreadsheets)

**Cons:**
- Zendesk's CSV export doesn't include ticket comments or attachments — you'll still need the API for those ([support.zendesk.com](https://support.zendesk.com/hc/en-us/articles/4408886165402-Exporting-ticket-user-or-organization-data-from-your-account?utm_source=openai))
- Export tools are not available on Zendesk Team plans
- No ongoing sync capability
- JSON exports on large accounts can take up to 24 hours

**Complexity:** Medium
**Scalability:** Good for extraction; limited by Plain's API rate limits on the load side.

### 4. Managed Migration Service

Hand the migration to a team that specializes in helpdesk data moves — [ClonePartner](https://clonepartner.com/migrate/zendesk-to-plain), or a similar provider.

**How it works:**
1. Provider connects to both Zendesk and Plain via API.
2. A sample migration runs on a subset of data for review.
3. Field mapping is configured and approved.
4. Full migration executes, often over a weekend.
5. Delta sync captures any records created during the migration window.

**When to use it:** You have limited engineering bandwidth, complex data (custom objects, multi-level relationships), or strict timelines.

**Pros:**
- No internal engineering time
- Handles edge cases the provider has seen before
- Usually includes validation and delta sync

**Cons:**
- Cost (varies by volume and complexity)
- Less control over transformation logic
- Requires vendor trust with API credentials

**Complexity:** Low (for your team)
**Scalability:** Handles enterprise volumes.

### 5. Custom ETL Pipeline

Deploy a purpose-built data pipeline (e.g., Airflow, dbt) to extract Zendesk data into a staging store, transform it, build ID crosswalks, and push to Plain through controlled workers.

**When to use it:** Multi-brand, compliance-heavy, M&A scenarios, custom objects, or when Zendesk is one of several source systems being consolidated.

**Pros:**
- Full auditability and replay capability
- Most reliable for large-scale migrations

**Cons:**
- Longest delivery time and most testing
- Risk of over-engineering for a one-time event

**Complexity:** High
**Scalability:** Enterprise.

### 6. Middleware Platforms (Zapier, Make, n8n)

Use an integration platform to orchestrate the data flow between Zendesk and Plain.

**When to use it:** You want lightweight, ongoing sync for a small subset of data — not a full historical migration.

**Pros:**
- No-code/low-code setup
- Good for ongoing sync of new tickets

**Cons:**
- Not designed for bulk historical migration
- Middleware rate limits stack on top of API rate limits
- Transformation logic is limited to what the platform supports
- Cost scales with volume (per-task pricing)

**Complexity:** Low
**Scalability:** Poor for bulk migration. Acceptable for low-volume ongoing sync.

### Approach Comparison

| Approach | Best For | Complexity | Custom Fields | Ongoing Sync | History Fidelity |
|---|---|---|---|---|---|
| Plain's Native Importer | Standard migrations, fast setup | Low | ❌ | ✅ (new records) | High |
| API-Based (Custom) | Full control, complex mapping | High | ✅ | Build-your-own | Very High |
| CSV + API Hybrid | One-time, offline transform | Medium | ✅ | ❌ | Medium |
| Managed Service | Limited eng bandwidth, complex data | Low (your side) | ✅ | ✅ (delta) | High |
| Custom ETL | Multi-system or regulated migration | High | ✅ | ✅ | Very High |
| Middleware (Zapier/Make) | Low-volume ongoing sync | Low | Limited | ✅ | Low |

**Recommendations by scenario:**

- **Small team, standard data, fast timeline:** Start with Plain's native Zendesk importer. It's included in your plan, it's fast, and it handles core objects.
- **Mid-size account with custom fields and org data:** API-based migration or managed service, depending on engineering availability.
- **Enterprise with 500K+ tickets and strict requirements:** Managed migration service or custom ETL. The edge cases at scale consume more engineering time than most teams expect.
- **Ongoing sync between Zendesk and Plain:** Plain's native importer supports this for new records. For more granular control, build a webhook-driven sync using Zendesk triggers + Plain's GraphQL API.

### Timeline Estimates

Migration timelines vary based on concrete variables, not abstract "size" categories. The primary factors:

| Variable | Impact on Timeline |
|---|---|
| **Ticket count** | <50K: 1–2 weeks. 50K–500K: 3–5 weeks. 500K+: 5–8 weeks. |
| **Custom field count** | Each custom field requires schema definition in Plain, type casting in the transform layer, and validation. 10+ active custom fields adds ~1 week. |
| **Attachment volume** | Attachment-heavy instances (>500GB) require dedicated download/upload workers and significantly extend the load phase. |
| **Organization complexity** | Heavy use of Zendesk Organizations with custom fields, shared tickets, and multi-org users adds ~1 week for mapping and testing. |
| **Multi-brand setup** | Each Zendesk brand requires separate extraction logic and tenant/label mapping in Plain. Adds 3–5 days per brand. |

These estimates assume one engineer working on the migration. The extraction phase is typically 20% of the effort; transformation and edge case handling is 50%; loading and validation is 30%.

## When Not to Build In-House

DIY migrations fail in predictable ways:

- **Broken relationships.** Customer-to-Company links, Tenant assignments, and thread-to-customer associations silently break when IDs don't map cleanly.
- **Incomplete conversation history.** Missing internal notes, orphaned attachments, or out-of-order messages make historical threads unusable.
- **Underestimated scope.** The extraction is the easy part. Transformation — handling edge cases, duplicates, encoding issues, and null fields — takes 3–5x the expected effort.
- **Hidden API costs.** Zendesk's incremental export endpoint is capped at 10 requests per minute. A million-ticket account takes hours just to extract, and a single dropped connection means re-running from the last cursor.

The failure mode is rarely "the API call failed." It's usually "the relationships looked fine until agents tried to work real tickets on Monday."

> [!WARNING]
> **Scale threshold:** If your Zendesk instance has over 50,000 tickets, multiple brands, or complex custom field dependencies, a DIY script will likely require multiple iterations due to payload limits, unmapped relational constraints, or rate limit exhaustion. Budget for at least two full test runs before the production migration.

## Pre-Migration Planning

### Data Audit Checklist

Before migrating, inventory everything in your Zendesk instance:

- [ ] **Tickets:** Total count, date range, statuses. How far back do you need?
- [ ] **Users (end-users):** Active vs. suspended vs. deleted. Deduplicate by email.
- [ ] **Organizations:** Which ones are actively used? Do they carry custom fields?
- [ ] **Agents:** Active and legacy. Legacy agents must exist in Plain to preserve assignment history.
- [ ] **Custom fields:** Ticket fields, user fields, org fields. Which are still in use? Document data types.
- [ ] **Tags:** Full list. Which map to Plain Labels?
- [ ] **Macros and triggers:** These won't migrate — document them for manual rebuild.
- [ ] **Attachments:** Total size. Any formats that might not transfer?
- [ ] **Knowledge base (Zendesk Guide):** Articles, categories, sections. Count and languages.
- [ ] **Satisfaction ratings:** Plain doesn't import CSAT data from Zendesk. Export separately (see [CSAT Data Handling](#csat-data-handling)).
- [ ] **Custom objects (Sunshine):** Inventory all object types, relationships, and record counts.

### Define Migration Scope

Not everything needs to move. Common exclusions:

- Spam and suspended tickets
- Test tickets and internal test users
- Tickets older than 2–3 years (unless required for compliance)
- Deleted or suspended user accounts
- Unused custom fields and tags

### Migration Strategy

| Strategy | Description | Risk Level | Best For |
|---|---|---|---|
| **Big bang** | Migrate everything at once, cut over on a set date | Medium | Small accounts, clear cutover windows |
| **Phased** | Migrate by object type or date range in stages | Low | Mid-size accounts needing validation between phases |
| **Newest-first** | Migrate recent data first, backfill historical data | Low | Teams that need to go live quickly |

For Zendesk to Plain, a big-bang cutover (migrating all historical data over a weekend) is the most common approach, paired with a delta sync just before go-live to catch tickets created during the migration window. Plain's native importer is essentially a big-bang approach with built-in ongoing sync. For API-based migrations, a phased or newest-first strategy reduces risk.

> [!NOTE]
> For pilot slices, use Zendesk's **Search Export** endpoint instead of filtering spreadsheets by hand. It supports cursor pagination up to 1,000 records per page and works well with queries like `status:open tags:migrate_to_plain created>2026-01-01`. ([developer.zendesk.com](https://developer.zendesk.com/api-reference/ticketing/ticket-management/search/?utm_source=openai))

## Data Model and Object Mapping

This is where migrations succeed or fail. Every Zendesk object needs a destination in Plain's schema.

### Object-Level Mapping

| Zendesk Object | Plain Equivalent | Notes |
|---|---|---|
| End-user | Customer | Matched by email (unique in Plain). Store Zendesk user ID in `externalId`. |
| Organization | Company + Tenant | Company is auto-assigned by email domain. Tenant maps to your product's workspace/org concept. Org custom fields need manual handling. |
| Ticket | Thread | Use `importThread` for historical data, not `createThread`. |
| Ticket comment (public) | Thread message (INBOUND/OUTBOUND) | Preserve timestamps and author attribution. |
| Ticket comment (internal) | Note | Internal notes import as Plain notes via the native importer. |
| Ticket tag | Label | 1:1 mapping. Create Labels in Plain before import if using API. |
| Custom ticket field | Thread Field | Must configure Thread Field schema in Plain first. Data types must match exactly — Plain's schema is strict. |
| Custom user field | Customer metadata | No direct equivalent — store in `externalId` or surface via Customer Cards. |
| Custom org field | Tenant metadata | No direct equivalent. Consider Customer Cards for live data. |
| Macro | Workflow / Rule | Must rebuild manually. No import path. |
| Trigger / Automation | Auto-responder / Rule | Must rebuild manually. |
| SLA Policy | SLA (per-tier) | Rebuild in Plain's tier-based SLA system. |
| Satisfaction rating | — | Not migrated. Export separately (see [CSAT Data Handling](#csat-data-handling)). |
| Ticket form | — | Plain uses Thread Fields instead of multiple ticket forms. |
| Custom objects (Sunshine) | — | No equivalent. See [Sunshine Custom Objects](#sunshine-custom-objects) for handling strategies. |
| Help Center article | Help Center article | See [Knowledge Base Migration](#knowledge-base-migration-zendesk-guide--plain-help-center). |

> [!WARNING]
> Plain assigns Companies automatically based on customer email domain. If your Zendesk Organizations don't align with email domains (e.g., shared support emails, partner accounts, multiple domains per org), you'll need to update Company assignments via the API after import. Free email providers (gmail.com, outlook.com, yahoo.com) will group unrelated customers into the same Company — handle these explicitly.

### Field-Level Mapping

| Zendesk Field | Plain Field | Transformation |
|---|---|---|
| `ticket.id` | `thread.externalId` | Prefix with source system, e.g. `zd_ticket_12345` |
| `ticket.subject` | `thread.title` | Direct map |
| `ticket.status` (new/open) | `status` = `TODO` | Map detail to `NEW_REPLY` or `IN_PROGRESS` where supported |
| `ticket.status` (pending) | `status` = `SNOOZED` | Maps to `WAITING_FOR_CUSTOMER` |
| `ticket.status` (on-hold) | `status` = `TODO` or `SNOOZED` | Choose based on your internal semantics — these are not the same operationally |
| `ticket.status` (solved/closed) | `status` = `DONE` | `DONE_MANUALLY_SET` for imported threads |
| `ticket.priority` | `priority` | Plain import uses `0=urgent, 1=high, 2=normal, 3=low` |
| `requester.email` | `customer.email` | Unique in Plain; deduplicate before load |
| `requester.id` | `customer.externalId` | Keep original source ID |
| `organization.id` | `company.externalId` or `tenant.externalId` | Decide once and stay consistent |
| `ticket.tags` | `labelTypeIds` | Normalize spelling and casing first |
| `custom_fields` | `threadFields` | Convert to declared schema and valid enums |
| `ticket.created_at` | `createdAt` | ISO 8601; use `importThread` to preserve original timestamp |
| `attachments` | `attachmentIds` | Upload first, then reference in message import |

## Migration Architecture and API Constraints

The data flow follows a standard ETL pattern:

```
Zendesk (REST API / Export) → Extract → Transform → Load → Plain (GraphQL API)
```

Maintain a durable ID crosswalk at every stage — Zendesk IDs to Plain IDs — so you can audit, resume, and roll back.

### Authentication Setup

**Zendesk:** Create an API token in **Admin Center → Apps and integrations → Zendesk API → Zendesk API Settings**. Enable token access. Use the format `{email}/token:{api_token}` for basic auth. For production migrations, Zendesk recommends OAuth — create an OAuth client in Admin Center and use the resulting access token. API tokens will be permanently deactivated on **April 30, 2027**. ([developer.zendesk.com](https://developer.zendesk.com/documentation/api-basics/authentication/api-tokens-to-oauth/?utm_source=openai))

**Plain:** Generate an API key in **Settings → API Keys**. Select the workspace you're migrating into. The key needs permissions for customer management, thread management, and attachment uploads. Store the key securely — it cannot be retrieved after creation.

### Extraction from Zendesk

The **Incremental Export API** is the recommended extraction path for tickets, users, and organizations:

```bash
# Fetch tickets updated since a given timestamp
curl https://{subdomain}.zendesk.com/api/v2/incremental/tickets/cursor.json?start_time=1609459200 \
  -u {email}/token:{api_token}
```

Key constraints:
- Incremental exports are limited to **10 requests per minute** (30 with the High Volume API add-on)
- Returns up to 1,000 records per page with cursor-based pagination
- Ticket comments require a separate call per ticket: `GET /api/v2/tickets/{id}/comments.json`
- Attachments must be downloaded individually from their `content_url`
- Archived tickets (closed > 120 days) are excluded from some standard API endpoints but are included in incremental exports

### Loading into Plain

Plain's GraphQL API accepts mutations for creating and updating every object type. For historical migrations, use the **import mutations** (`importThread`, `importThreadMessages`) rather than standard create mutations — they preserve original timestamps and don't trigger SLAs or auto-responders. ([plain.com](https://www.plain.com/docs/graphql/threads/import))

```graphql
# Upsert a customer
mutation {
  upsertCustomer(input: {
    identifier: { emailAddress: "user@example.com" }
    onCreate: {
      fullName: "Jane Doe"
      email: { email: "user@example.com" isVerified: true }
      externalId: "zd_user_12345"
    }
    onUpdate: {
      fullName: { value: "Jane Doe" }
      externalId: { value: "zd_user_12345" }
    }
  }) {
    customer { id fullName }
    error { message }
  }
}
```

```graphql
# Import a historical thread
mutation {
  importThread(input: {
    customerIdentifier: { emailAddress: "user@example.com" }
    externalId: "zd_ticket_98765"
    title: "Cannot access API dashboard"
    status: DONE
    createdAt: "2024-03-15T10:00:00Z"
    labelTypeIds: ["lt_billing", "lt_bug"]
    components: [
      { componentText: { text: "I'm getting a 403 error..." } }
    ]
  }) {
    thread { id status }
    error { message }
  }
}
```

Plain's import mutations are **idempotent on `externalId`**, so you can safely resume after failures without creating duplicate threads or messages. ([plain.com](https://www.plain.com/docs/graphql/threads/import))

### Rate Limit Management

| Platform | Limit | Scope |
|---|---|---|
| Zendesk (Team) | 200 req/min | Account-wide |
| Zendesk (Growth/Professional) | 400–700 req/min | Account-wide |
| Zendesk (Enterprise Plus) | 2,500 req/min | Account-wide |
| Zendesk Incremental Export | 10 req/min (30 with High Volume API add-on) | Endpoint-specific |
| Plain | Varies by plan; check `x-ratelimit-limit` response header for your workspace's exact limit | API key, workspace-scoped |

Both platforms return rate limit headers. Your migration script must read `Retry-After` (Zendesk) or `x-ratelimit-reset` (Plain) headers and back off accordingly. Never hard-code sleep intervals — use the headers.

> [!NOTE]
> **Determining your Plain rate limit:** Make any authenticated API call and inspect the response headers. `x-ratelimit-limit` returns your workspace's per-minute cap. `x-ratelimit-remaining` shows how many requests you have left in the current window. Build your backoff logic around these values. ([plain.com](https://www.plain.com/docs/graphql/rate-limits))

## Step-by-Step Migration Process

### Step 1: Configure Plain Workspace

Before importing any data:

1. Create your Plain workspace and configure support channels (email, Slack, etc.).
2. Generate an **API key** in Settings → API Keys with permissions for customer, thread, and attachment management.
3. Set up **Thread Field schemas** in Settings → Thread Fields for any Zendesk custom ticket fields you need to preserve. Match data types exactly — Plain rejects mutations with type mismatches.
4. Create **Labels** matching your Zendesk tags (or the subset you want to keep).
5. Create **Tenants** for each Zendesk Organization you want to map.
6. Add **Users** (agents) to Plain. Agent IDs must exist before you can assign threads.
7. Configure **Tiers** if you use Zendesk SLA policies tied to organization tiers.

### Step 2: Extract from Zendesk

Use the incremental export for bulk extraction. Handle rate limits and HTTP errors with header-based backoff:

```python
import requests
import time
import json
import logging

ZENDESK_SUBDOMAIN = "yourcompany"
ZENDESK_EMAIL = "admin@yourcompany.com"
ZENDESK_TOKEN = "your_api_token"

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def extract_tickets(start_time=0):
    url = f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/incremental/tickets/cursor.json"
    params = {"start_time": start_time}
    auth = (f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN)
    
    all_tickets = []
    while url:
        resp = requests.get(url, params=params, auth=auth)
        
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", 60))
            logger.warning(f"Rate limited. Retrying after {retry_after}s")
            time.sleep(retry_after)
            continue
        
        if resp.status_code >= 500:
            logger.error(f"Server error {resp.status_code}. Retrying in 30s")
            time.sleep(30)
            continue
            
        if resp.status_code >= 400:
            logger.error(f"Client error {resp.status_code}: {resp.text}")
            raise Exception(f"Unrecoverable error: {resp.status_code}")
        
        data = resp.json()
        all_tickets.extend(data.get("tickets", []))
        logger.info(f"Extracted {len(all_tickets)} tickets so far")
        url = data.get("after_url") if not data.get("end_of_stream") else None
        params = {}  # after_url includes params
    return all_tickets
```

### Step 3: Transform Data

The transformation layer handles:

- **Status mapping:** `new`, `open` → `TODO`; `pending` → `SNOOZED`; `on-hold` → `TODO` or `SNOOZED` based on your semantics; `solved`, `closed` → `DONE`
- **User deduplication:** Zendesk allows multiple users with similar emails; Plain enforces email uniqueness
- **Organization → Tenant mapping:** Map Zendesk `organization_id` to Plain Tenant `externalId`
- **Custom field conversion:** Cast Zendesk field values to match Plain Thread Field types. Plain's schema is strict — passing a string into an enum field fails the entire mutation.
- **Tag → Label mapping:** Normalize spelling and casing, filter out irrelevant tags before creating Labels
- **HTML → compatible format:** Convert Zendesk HTML comments into the format expected by Plain (often Markdown or plain text, depending on the component used)

### Step 4: Load into Plain

Use Plain's TypeScript SDK (`@team-plain/typescript-sdk`) or direct GraphQL calls. The high-level migration loop:

```python
for zendesk_ticket in zendesk_incremental_export():
    customer = plain_upsert_customer(
        email=zendesk_ticket.requester_email,
        external_id=f'zd_user_{zendesk_ticket.requester_id}',
        full_name=zendesk_ticket.requester_name,
    )

    thread = plain_import_thread(
        customer_email=zendesk_ticket.requester_email,
        external_id=f'zd_ticket_{zendesk_ticket.id}',
        title=zendesk_ticket.subject,
        status=map_status(zendesk_ticket.status),
        priority=map_priority(zendesk_ticket.priority),
        created_at=zendesk_ticket.created_at,
        labels=map_tags(zendesk_ticket.tags),
        thread_fields=map_custom_fields(zendesk_ticket.custom_fields),
        tenant_id=resolve_tenant(zendesk_ticket),
    )

    for batch in chunk(map_comments_and_notes(zendesk_ticket), 25):
        uploaded_attachment_ids = upload_needed_attachments(batch)
        plain_import_thread_messages(
            thread_id=thread.id,
            messages=attach_ids(batch, uploaded_attachment_ids),
        )
```

`importThreadMessages` batches up to **25 messages per call**. Upload attachments before referencing them — Plain deletes unused uploaded attachments after 24 hours. ([plain.com](https://www.plain.com/docs/graphql/attachments))

### Error Handling for Plain's GraphQL API

Plain's strict schema means you'll encounter errors beyond rate limits. Handle them explicitly:

```python
def handle_plain_response(response, context_id):
    """Handle Plain GraphQL mutation responses."""
    data = response.json().get("data", {})
    
    # Check for top-level GraphQL errors
    if response.json().get("errors"):
        for error in response.json()["errors"]:
            logger.error(f"GraphQL error for {context_id}: {error['message']}")
            # Schema violations (wrong type, invalid enum) are not retryable
            if "validation" in error.get("extensions", {}).get("code", ""):
                log_skip(context_id, error["message"])
                return None
            raise RetryableError(error["message"])
    
    # Check for mutation-level errors (e.g., from upsertCustomer)
    mutation_result = list(data.values())[0] if data else {}
    if mutation_result.get("error"):
        error = mutation_result["error"]
        logger.error(f"Mutation error for {context_id}: {error['message']}")
        # Log and skip non-retryable errors; raise retryable ones
        if error.get("type") == "VALIDATION":
            log_skip(context_id, error["message"])
            return None
        raise RetryableError(error["message"])
    
    return mutation_result

def log_skip(context_id, reason):
    """Log skipped records for post-migration review."""
    with open("skipped_records.jsonl", "a") as f:
        f.write(json.dumps({"id": context_id, "reason": reason}) + "\n")
```

Common failure patterns:
- **`VALIDATION` errors:** A Thread Field received a string value but expected an enum. Fix in the transform layer; these are not retryable.
- **`NOT_FOUND` errors:** A referenced customer, tenant, or label doesn't exist. Check import ordering.
- **`bulk_partial_failure` on `importThreadMessages`:** Some messages in the batch succeeded, others failed. Log per-message results. Don't retry the entire batch — only retry the failed messages. ([plain.com](https://www.plain.com/docs/graphql/threads/import))

> [!TIP]
> **Use source IDs everywhere.** Plain's historical import mutations are idempotent on `externalId`, so you can resume after failures without duplicate threads or messages. Always prefix IDs with the source system (e.g., `zd_ticket_12345`) to avoid collisions if you migrate from multiple platforms later.

### Step 5: Rebuild Relationships

After loading core objects:

1. **Assign Customers to Tenants** using the `addCustomerToTenants` mutation.
2. **Update Company assignments** if email-domain-based auto-assignment doesn't match your org structure.
3. **Apply Thread Fields** to threads with custom data.
4. **Add Labels** to threads based on tag mapping.

### Step 6: Handle Attachments

Zendesk attachments are hosted on their CDN. To migrate them, your script must download the file from Zendesk's `content_url`, upload it to Plain's attachment endpoint, and reference the resulting asset ID in the corresponding thread message. Plain allows up to 100 attachments per message, but email attachments cap at 6 MB. ([plain.com](https://www.plain.com/docs/graphql/attachments))

For inline images embedded directly in Zendesk comment HTML, parse the HTML, download the image, upload it to Plain, and rewrite the image source URL. Zendesk-hosted inline images may require authentication to view, so they won't render if you carry over the raw HTML.

### Step 7: Validate

See the [Validation and Testing](#validation-and-testing) section below.

## Knowledge Base Migration (Zendesk Guide → Plain Help Center)

Zendesk Guide articles don't transfer via Plain's native importer. This requires a separate migration path.

### Zendesk Guide Data Model

Zendesk Guide organizes content hierarchically:
- **Categories** → **Sections** → **Articles**
- Articles contain HTML body content, optional attachments, labels, and metadata (author, creation date, draft/published status)

### Extraction

Use the Zendesk Help Center API to extract articles:

```bash
# List all articles (paginated, 100 per page)
curl https://{subdomain}.zendesk.com/api/v2/help_center/articles.json?per_page=100 \
  -u {email}/token:{api_token}

# Get article body and metadata
curl https://{subdomain}.zendesk.com/api/v2/help_center/articles/{article_id}.json \
  -u {email}/token:{api_token}

# List article attachments (inline images, PDFs)
curl https://{subdomain}.zendesk.com/api/v2/help_center/articles/{article_id}/attachments.json \
  -u {email}/token:{api_token}
```

Key fields to extract: `title`, `body` (HTML), `section_id`, `draft`, `promoted`, `created_at`, `updated_at`, `label_names`, and `attachments`.

### Transformation

- Convert Zendesk Guide HTML to the format expected by Plain's Help Center (check Plain's docs for supported content format — typically Markdown or structured content)
- Map Zendesk Categories/Sections to Plain Help Center collections or categories
- Download and re-host any inline images — Zendesk-hosted image URLs will break after migration
- Preserve the section hierarchy as much as Plain's Help Center structure allows

### Loading

Plain's Help Center API supports article creation programmatically. Create collections first (mapping from Zendesk categories/sections), then create articles within those collections.

### What Doesn't Transfer

- Article view counts and vote data
- Community posts and comments (Zendesk Gather)
- Content blocks shared across articles
- Article-level permissions (restricted articles by user segment)

## CSAT Data Handling

Plain doesn't import Zendesk satisfaction ratings. To preserve this data:

### Export CSAT Data from Zendesk

```bash
# Export satisfaction ratings (paginated)
curl https://{subdomain}.zendesk.com/api/v2/satisfaction_ratings.json?per_page=100 \
  -u {email}/token:{api_token}
```

Each rating includes: `id`, `score` (good/bad), `comment`, `ticket_id`, `requester_id`, `assignee_id`, `created_at`, and `updated_at`.

### Storage Options

1. **CSV/JSON archive:** Export all ratings with associated ticket IDs. Store in S3 or your data warehouse. Cross-reference using `zd_ticket_{id}` external IDs if you need to look up CSAT for a specific thread later.
2. **Data warehouse:** Load into your analytics database (BigQuery, Snowflake, etc.) for historical CSAT reporting.
3. **Thread Fields:** If you need CSAT visible per-thread in Plain, create a Thread Field (e.g., `zendesk_csat_score` with values `good`/`bad`) and populate it during migration. This is a static snapshot — it won't update.

## Sunshine Custom Objects

Zendesk Sunshine custom objects (custom object types, records, and relationships) have no equivalent in Plain. Three handling strategies, depending on how the data is used:

### Strategy 1: Flatten into Thread Fields

If custom objects contain per-ticket metadata (e.g., a "Contract" object linked to tickets), extract the relevant fields and map them to Thread Fields.

Example: A Zendesk custom object "Contract" with fields `contract_id`, `renewal_date`, `plan_tier`:

```python
# Extract custom object records linked to a ticket
def get_ticket_custom_object_data(ticket_id):
    # Zendesk Custom Object Records API
    resp = requests.get(
        f"https://{SUBDOMAIN}.zendesk.com/api/v2/custom_objects/contract/records",
        params={"filter[ticket_id]": ticket_id},
        auth=auth
    )
    records = resp.json().get("custom_object_records", [])
    if records:
        return {
            "contract_id": records[0]["custom_object_fields"]["contract_id"],
            "renewal_date": records[0]["custom_object_fields"]["renewal_date"],
            "plan_tier": records[0]["custom_object_fields"]["plan_tier"],
        }
    return {}
```

Create corresponding Thread Fields in Plain (`contract_id` as string, `renewal_date` as string, `plan_tier` as enum) and populate during thread import.

### Strategy 2: Surface via Customer Cards

If custom objects contain customer-level data (e.g., subscription details, product usage), build a Customer Card that pulls from your own backend. This is the preferred approach when the data is dynamic — Customer Cards fetch fresh data on each load rather than storing a static snapshot.

### Strategy 3: Archive Externally

If custom objects are used for internal workflows that won't continue in Plain, export the records and store them in your data warehouse for reference. Use the Zendesk Custom Objects API to extract all records:

```bash
curl https://{subdomain}.zendesk.com/api/v2/custom_objects/{object_type}/records.json \
  -u {email}/token:{api_token}
```

## Multi-Brand Zendesk Setups

If your Zendesk instance uses multiple brands (separate support portals with distinct URLs, email addresses, and branding), each brand requires separate handling:

1. **Map brands to Plain structure.** Options:
   - **Separate Plain workspaces** per brand (full isolation, separate billing)
   - **Labels** to tag threads by brand origin (simpler, single workspace)
   - **Tenants** to separate brand-level views (if brands map to customer segments)

2. **Extract per-brand.** Use the `brand_id` filter on Zendesk's ticket API:
   ```bash
   curl "https://{subdomain}.zendesk.com/api/v2/tickets.json?filter [brand_id]={brand_id}" \
     -u {email}/token:{api_token}
   ```

3. **Handle cross-brand users.** A single Zendesk user can submit tickets across brands. In Plain, this customer will have one record regardless of which brand the original ticket belonged to.

4. **Knowledge base per brand.** Each Zendesk brand can have its own Help Center. Extract articles per brand and decide whether to consolidate or keep separate in Plain.

## Edge Cases and Challenges

These are the issues that burn teams who don't plan for them:

- **Duplicate customers.** Zendesk allows multiple user records with overlapping emails (especially suspended/deleted users). Plain enforces email uniqueness. Deduplicate before loading — use the most recently active record.
- **Missing requesters.** Some Zendesk tickets have deleted or anonymous requesters. Create a fallback customer in Plain (e.g., "Unknown Customer") to maintain thread integrity.
- **Multi-organization users.** Zendesk supports users belonging to multiple organizations. Plain assigns one Company per customer. Decide which org wins, or use Tenants as the secondary grouping.
- **Relationship ordering.** If you import a thread with a `tenantId`, the customer must already be a member of that tenant. Load memberships first or fail fast. ([plain.com](https://www.plain.com/docs/graphql/threads/import))
- **Side conversations.** Zendesk Side Conversations (child tickets, Slack messages, emails) have no direct Plain equivalent. Import them as notes or separate threads.
- **Suspended tickets.** Tickets caught by Zendesk's spam filter. Decide whether to migrate or discard them.
- **Ticket followers and CCs.** Zendesk tickets can have CCs and followers. Plain threads support multiple participants but the mapping isn't 1:1. Plan how CC'd users appear.
- **Partial failures.** `importThreadMessages` can return per-message errors and a top-level `bulk_partial_failure`. Log at message granularity, not just ticket level. ([plain.com](https://www.plain.com/docs/graphql/threads/import))
- **Status nuance.** Zendesk `pending` and `on-hold` are not the same thing operationally. `Pending` stops the SLA clock and signals "waiting for the customer." `On-hold` stops the SLA clock but signals "waiting for an internal/third-party resolution." Don't map both blindly to one Plain state without understanding your team's workflow. ([support.zendesk.com](https://support.zendesk.com/hc/en-us/articles/8263915942938-About-the-ticket-lifecycle-and-ticket-statuses))
- **Free email domains.** Customers using gmail.com, outlook.com, yahoo.com, etc. will all be auto-assigned to the same Company in Plain. Override these assignments via API or exclude free domains from Company auto-assignment.

## Limitations and Constraints

Be explicit about what you're giving up:

| Limitation | Impact |
|---|---|
| **No custom objects in Plain** | Zendesk custom objects (Sunshine) have no equivalent. See [Sunshine Custom Objects](#sunshine-custom-objects) for handling strategies. |
| **Three thread statuses** | Zendesk's six statuses (plus custom statuses) compress to Todo, Done, Snoozed. Historical status granularity is lost. |
| **One email per customer** | Zendesk users can have multiple identities. Plain enforces one email per customer. |
| **No native ticket forms** | Zendesk Ticket Forms → Thread Fields + frontend forms. Routing logic must be rebuilt. |
| **No satisfaction ratings import** | Zendesk CSAT data doesn't transfer. See [CSAT Data Handling](#csat-data-handling). |
| **No audit log migration** | Zendesk ticket audit trails (field change history) don't have a Plain equivalent. |
| **Company auto-assignment** | Plain sets Company by email domain. This doesn't work for shared-domain setups (gmail.com, outlook.com) or organizations with multiple domains. |
| **Schema strictness** | Plain's GraphQL schema is strict. Passing a string into an enum Thread Field fails the entire mutation. Your transformation layer must type-cast all data. |

> [!NOTE]
> Plain's **Customer Cards** can fill some gaps. If you need to display Zendesk organization data, billing status, or custom object data in Plain, build a Customer Card that pulls from your own backend or a cached copy of the Zendesk data. Customer Cards are rendered live on each load, so they always show current data from your system of record.

## Validation and Testing

Never run a migration directly into your production Plain workspace without testing first. Use Plain's free trial to run a test migration with a subset of data before committing.

### Record Count Comparison

After migration, compare counts across every object type:

| Object | Zendesk Count | Plain Count | Match? |
|---|---|---|---|
| End-users / Customers | | | |
| Organizations / Tenants | | | |
| Tickets / Threads | | | |
| Ticket comments / Timeline entries | | | |
| Tags / Labels | | | |
| Attachments | | | |

### Field-Level Validation

For a random sample (5–10% of records, minimum 100):

- Verify customer name, email, and `externalId` match
- Confirm thread status, title, and label assignments
- Check that conversation history (messages + internal notes) is complete and in order
- Validate Thread Field values against source Zendesk custom fields
- Open attachments to confirm they're not corrupted
- Verify that `createdAt` timestamps match the original Zendesk ticket creation dates

### UAT Process

1. Have 2–3 agents use Plain for a day on migrated data.
2. Ask them to find specific historical tickets by customer or subject.
3. Verify that conversation context is sufficient for ongoing support.
4. Test reply flows, label assignment, and thread status changes.

### Rollback Plan

For API-based migrations:

- Tag all migrated records with a `source: zendesk_migration` label
- Maintain a mapping file of Zendesk IDs → Plain IDs
- If rollback is needed, use the mapping file to identify and delete migrated records via API
- Keep Zendesk active (read-only) for at least 2 weeks post-cutover

For post-migration QA, pair this guide with [Post-Migration QA: 20 Tests to Run After Your Help Desk Data Migration](https://clonepartner.com/blog/blog/help-desk-data-migration-qa-checklist/).

## Post-Migration Tasks

### Rebuild Automations

Nothing migrates automatically. Rebuild in Plain:

- **Auto-responders:** Configure for each support channel.
- **Rules / Workflows:** Recreate Zendesk triggers as Plain rules (assignment, labeling, SLA escalation).
- **SLA policies:** Set up per-tier SLAs in Plain's tier system.
- **Integrations:** Reconnect Linear, Jira, GitHub, Slack, and any webhook-based integrations.

### Reroute Inbound Channels

Update your support email addresses (e.g., support@company.com) from Zendesk to Plain's inbound email handlers.

### Team Onboarding

- Train agents on Plain's keyboard-first UI — it's substantially different from Zendesk.
- Walk through the `Todo` / `Done` / `Snoozed` workflow, Thread Fields, Labels, and Tenant-based filtering.
- Set up Customer Cards to surface data from your internal systems.
- Document the new escalation workflow (Plain → Linear/Jira).

### Monitor for Data Gaps

For the first 2 weeks post-migration:

- Monitor for missing conversation history reported by agents
- Check that auto-sync (if using native importer) is capturing new Zendesk data
- Verify that Customer Card data is loading correctly
- Watch for rate limit errors in any ongoing sync scripts
- Review the `skipped_records.jsonl` log for any records that failed during migration

## Best Practices

1. **Back up everything first.** Run a full JSON export from Zendesk before starting. Store it in S3 or equivalent.
2. **Freeze Zendesk configuration.** Implement a change freeze at least two weeks before migration. No new custom fields, no new macros.
3. **Migrate newest data first.** If using an API-based approach, start with the last 90 days. Let agents validate on recent, familiar data.
4. **Don't migrate what you don't need.** Spam, test tickets, and 5-year-old solved tickets rarely add value. Define a cutoff date.
5. **Preserve Zendesk IDs.** Always store the original Zendesk ID in Plain's `externalId` field. You'll need this for troubleshooting, auditing, and post-migration fixes.
6. **Run a delta sync.** Run your main migration over a weekend. Before go-live, run a delta script that only pulls tickets updated since the main migration started.
7. **Keep Zendesk alive (briefly).** Maintain your Zendesk instance in a read-only state for at least 30 days post-migration as a safety net.
8. **Use idempotent imports.** Plain's `importThread` and `importThreadMessages` mutations are idempotent on `externalId`, so you can resume after failures without duplicates.
9. **Log everything.** Write every skipped record, transformation decision, and error to a structured log file. Post-migration debugging without logs is guesswork.
10. **Test with production data.** Use a full copy of your Zendesk data for test runs, not a synthetic subset. Edge cases only surface with real data.

For a pre-migration walkthrough, see our [Zendesk Migration Checklist](https://clonepartner.com/blog/blog/zendesk-migration-checklist/) and [Help Desk Data Migration Playbook](https://clonepartner.com/blog/blog/help-desk-data-migration-playbook/).

## Making the Call

If your migration scope fits within Plain's native importer — users, tickets, messages, tags — start there. It's the lowest-risk, lowest-effort path, and it includes ongoing sync for new records.

If you need custom fields, organization-level data, knowledge base content, attachments with full fidelity, or selective filtering, you're looking at an API-based build or a managed service. The decision between those two comes down to engineering bandwidth and timeline.

| Decision Factor | Build In-House | Use a Migration Service |
|---|---|---|
| Engineering team has 2+ weeks available | ✅ | — |
| Custom fields < 5, no Sunshine objects | ✅ | — |
| Single Zendesk brand | ✅ | — |
| Custom fields > 10 or Sunshine objects | — | ✅ |
| Multi-brand Zendesk | — | ✅ |
| Go-live deadline < 2 weeks away | — | ✅ |
| Attachment volume > 500GB | — | ✅ |

> Migrating from Zendesk to Plain and need help with custom fields, organizations, or large-scale data? ClonePartner handles the full migration — extraction, transformation, loading, and validation.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Does Plain have a built-in Zendesk importer?

Yes. Plain offers a native Zendesk importer under Settings → Zendesk importer. It imports end users as Customers, tickets as Threads, all messages (including internal notes), and ticket tags as Labels. After the initial import, it auto-syncs new data. However, it does not import custom fields, organizations, or satisfaction ratings, and changes to existing Zendesk tickets (status, priority, assignee) do not keep syncing.

### How do Zendesk ticket statuses map to Plain?

Zendesk uses six statuses (New, Open, Pending, On-hold, Solved, Closed). Plain has three: Todo, Done, and Snoozed. New/Open map to Todo, Pending maps to Snoozed (Waiting for Customer), Solved/Closed map to Done. On-hold can map to either Todo or Snoozed depending on your team's semantics — these two Zendesk statuses are not operationally identical, so don't map them blindly to the same Plain state.

### What data doesn't transfer from Zendesk to Plain?

Macros, triggers, automations, SLA policies, ticket forms, satisfaction ratings, audit logs, and Zendesk custom objects (Sunshine) have no migration path. They must be rebuilt manually or surfaced via Plain's Customer Cards and rule engine. Custom ticket fields also don't carry over through the native importer — you need API-based scripting with importThread for those.

### How do Zendesk organizations map to Plain?

Plain has two organizational concepts: Companies (auto-assigned by customer email domain) and Tenants (which map to your product's workspace or org concept). Zendesk Organizations don't import directly via the native importer. Use Company for the business identity and Tenant when you need to model product workspaces. You'll need to create Tenants via the API and assign customers through scripting.

### Do I need OAuth for a Zendesk API-based migration?

For new integrations, yes. Zendesk recommends OAuth and has announced that API tokens created in Admin Center will be permanently deactivated on April 30, 2027. If you're building a migration script now, use OAuth to avoid having to redo authentication later.
