Skip to content

Zendesk to Plain Migration: The Complete Technical Guide

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.

Rishabh Rishabh · · 28 min read
Zendesk to Plain Migration: The Complete Technical Guide
TALK TO AN ENGINEER

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

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)

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)

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)
  • 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)
  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 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)
  • 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, 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).
  • 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.

Info

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)

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).
Ticket form Plain uses Thread Fields instead of multiple ticket forms.
Custom objects (Sunshine) No equivalent. See Sunshine Custom Objects for handling strategies.
Help Center article Help Center article See Knowledge Base Migration.
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)

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:

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

# 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 }
  }
}
# 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)

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.

Info

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)

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:

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, openTODO; pendingSNOOZED; on-holdTODO or SNOOZED based on your semantics; solved, closedDONE
  • 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:

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)

Error Handling for Plain's GraphQL API

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

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)
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)

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

  • CategoriesSectionsArticles
  • 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:

# 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

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

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

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:

    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)
  • 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)
  • 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)
  • 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 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.
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.
Info

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.

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 and 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

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.

More from our Blog

Zendesk Migration Checklist
Checklist/Zendesk

Zendesk Migration Checklist

Complete Zendesk migration checklist with pre-migration planning, API-based data transfer, manual setup, and post-migration validation steps

Tejas Mondeeri Tejas Mondeeri · · 4 min read
Help Desk Data Migration Playbook: What Data to Move and What to Leave Behind
Help Desk

Help Desk Data Migration Playbook: What Data to Move and What to Leave Behind

This definitive playbook answers the single most critical question: "What data do we actually need to move?". This strategic guide helps you declutter and decide what's precious and what's junk. We provide a clear breakdown of the non-negotiable Tier 1 data, like tickets , knowledge bases , and user profiles, versus the Tier 2 data that provides rich context, like automations and organizations. Use this as your strategic checklist to avoid common mistakes and ensure a flawless, functional new help desk.

Raaj Raaj · · 7 min read
Post-Migration QA: 20 Tests to Run After Your Help Desk Data Migration
Help Desk

Post-Migration QA: 20 Tests to Run After Your Help Desk Data Migration

Ensure your help desk migration is a success with this comprehensive 20-point post-migration QA checklist. This expert guide details the 20 essential tests needed to validate your data integrity, system functionality, user-friendliness, and performance . Learn exactly how to check everything from ticket data, attachments, and knowledge base articles to critical workflows, automations, and integrations before you go live. This process is your final line of defense against lost tickets, broken workflows, and unhappy customers.

Raaj Raaj · · 8 min read