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.
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:
- Enter your Zendesk subdomain and authenticate.
- Plain imports all end users as Customers, all tickets as Threads, all messages (including internal notes) as notes, and all ticket tags as Labels.
- Closed/Solved tickets become DONE threads; everything else becomes TODO.
- 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:
- Extract users, organizations, tickets, and comments from Zendesk using incremental export endpoints.
- Transform data: map statuses, flatten organization data into Tenant/Company structures, convert custom fields to Thread Field schemas.
- Load into Plain using GraphQL mutations:
upsertCustomer,importThread,importThreadMessages, and the attachment upload flow. (plain.com) - 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:
- Enable data exports in Zendesk (account owner may need to contact Zendesk support to activate this).
- Export tickets, users, and organizations as JSON or CSV from Admin Center → Account → Tools → Reports → Export.
- Transform the exported files: clean data, map fields, restructure for Plain's model.
- 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:
- Provider connects to both Zendesk and Plain via API.
- A sample migration runs on a subset of data for review.
- Field mapping is configured and approved.
- Full migration executes, often over a weekend.
- 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."
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.
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. |
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.
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:
- Create your Plain workspace and configure support channels (email, Slack, etc.).
- Generate an API key in Settings → API Keys with permissions for customer, thread, and attachment management.
- 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.
- Create Labels matching your Zendesk tags (or the subset you want to keep).
- Create Tenants for each Zendesk Organization you want to map.
- Add Users (agents) to Plain. Agent IDs must exist before you can assign threads.
- 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_ticketsStep 3: Transform Data
The transformation layer handles:
- Status mapping:
new,open→TODO;pending→SNOOZED;on-hold→TODOorSNOOZEDbased 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_idto Plain TenantexternalId - 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:
VALIDATIONerrors: A Thread Field received a string value but expected an enum. Fix in the transform layer; these are not retryable.NOT_FOUNDerrors: A referenced customer, tenant, or label doesn't exist. Check import ordering.bulk_partial_failureonimportThreadMessages: 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)
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:
- Assign Customers to Tenants using the
addCustomerToTenantsmutation. - Update Company assignments if email-domain-based auto-assignment doesn't match your org structure.
- Apply Thread Fields to threads with custom data.
- 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:
- 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:
# 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
- 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. - Data warehouse: Load into your analytics database (BigQuery, Snowflake, etc.) for historical CSAT reporting.
- Thread Fields: If you need CSAT visible per-thread in Plain, create a Thread Field (e.g.,
zendesk_csat_scorewith valuesgood/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:
-
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)
-
Extract per-brand. Use the
brand_idfilter on Zendesk's ticket API:curl "https://{subdomain}.zendesk.com/api/v2/tickets.json?filter [brand_id]={brand_id}" \ -u {email}/token:{api_token} -
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.
-
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.
importThreadMessagescan return per-message errors and a top-levelbulk_partial_failure. Log at message granularity, not just ticket level. (plain.com) - Status nuance. Zendesk
pendingandon-holdare not the same thing operationally.Pendingstops the SLA clock and signals "waiting for the customer."On-holdstops 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. |
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
externalIdmatch - 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
createdAttimestamps match the original Zendesk ticket creation dates
UAT Process
- Have 2–3 agents use Plain for a day on migrated data.
- Ask them to find specific historical tickets by customer or subject.
- Verify that conversation context is sufficient for ongoing support.
- Test reply flows, label assignment, and thread status changes.
Rollback Plan
For API-based migrations:
- Tag all migrated records with a
source: zendesk_migrationlabel - 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/Snoozedworkflow, 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.jsonllog for any records that failed during migration
Best Practices
- Back up everything first. Run a full JSON export from Zendesk before starting. Store it in S3 or equivalent.
- Freeze Zendesk configuration. Implement a change freeze at least two weeks before migration. No new custom fields, no new macros.
- Migrate newest data first. If using an API-based approach, start with the last 90 days. Let agents validate on recent, familiar data.
- Don't migrate what you don't need. Spam, test tickets, and 5-year-old solved tickets rarely add value. Define a cutoff date.
- Preserve Zendesk IDs. Always store the original Zendesk ID in Plain's
externalIdfield. You'll need this for troubleshooting, auditing, and post-migration fixes. - 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.
- Keep Zendesk alive (briefly). Maintain your Zendesk instance in a read-only state for at least 30 days post-migration as a safety net.
- Use idempotent imports. Plain's
importThreadandimportThreadMessagesmutations are idempotent onexternalId, so you can resume after failures without duplicates. - Log everything. Write every skipped record, transformation decision, and error to a structured log file. Post-migration debugging without logs is guesswork.
- 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.

